Initial commit
This commit is contained in:
commit
90236eaf4c
0
, b._is_market_prompt(t),
Normal file
0
, b._is_market_prompt(t),
Normal file
152
AGENTS.remote.live.md
Normal file
152
AGENTS.remote.live.md
Normal file
@ -0,0 +1,152 @@
|
||||
# AGENTS.md - OpenClaw Workspace Rules
|
||||
|
||||
## 1) Core Behavior
|
||||
|
||||
- Execute first, explain later. Do not throw generic blockers to the user.
|
||||
- Before asking user for help, try at least 2 autonomous recoveries.
|
||||
- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired).
|
||||
- Do not fabricate data. Every conclusion must come from tool results.
|
||||
|
||||
## 2) Routing Priority (Highest to Lowest)
|
||||
|
||||
### A. Xiaohongshu Tasks (override all market routes)
|
||||
|
||||
Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情
|
||||
|
||||
Required sequence:
|
||||
1. `exec: xhs-api health`
|
||||
2. `exec: xhs-api login-status`
|
||||
3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`
|
||||
4. Then use:
|
||||
- `xhs-api search "<keyword>"`
|
||||
- `xhs-api detail <id> <xsecToken> true`
|
||||
|
||||
### B. Sales / Aftersale / Refund / Performance Tasks (SQL only)
|
||||
|
||||
Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存
|
||||
|
||||
Hard rules:
|
||||
- Must route to `company-sales-db-analysis`.
|
||||
- Do not use yuce website for sales/aftersale tasks.
|
||||
- Do not use web_search/web_fetch for these tasks.
|
||||
- Must run at least one `exec` data query before final response.
|
||||
- Do not call `tts` unless user explicitly asks voice output.
|
||||
|
||||
Mandatory first checks (once per turn):
|
||||
1. `exec: company-db health`
|
||||
2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/`
|
||||
|
||||
Template mapping:
|
||||
- 店铺周报 -> `shop_weekly_report.sql`
|
||||
- SKU结构 -> `sku_structure_report.sql`
|
||||
- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 全量SKU缺货预警明细 -> `exec: stock-risk-alerts --days 30 --limit 500`
|
||||
- 全量SKU缺货预警推送飞书(结构化字段) -> `exec: stock-risk-to-feishu --days 30 --top 100`
|
||||
- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000`
|
||||
|
||||
Product info output minimum fields:
|
||||
- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议
|
||||
- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态
|
||||
- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天)
|
||||
- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)
|
||||
- 若涉及库存/补货,必须追加:近30天在售商品ID数量(按渠道)+ 商品ID对应店铺名/运营人 + 采购/工厂/仓库信息
|
||||
- 库存预警(强制):
|
||||
- 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)`
|
||||
- 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急`
|
||||
- 退款原因/售后 -> `after_sale_360_report.sql`
|
||||
- 费比异常 -> `fee_ratio_alert.sql`
|
||||
- 物流时效 -> `logistics_sla_report.sql`
|
||||
- 运费效率 -> `freight_efficiency_report.sql`
|
||||
- 客服热点 -> `customer_service_hotspot_report.sql`
|
||||
- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
|
||||
- 输出需包含:客服能力总分与等级(S/A/B/C/D)
|
||||
- 安装服务 -> `install_service_quality_report.sql`
|
||||
- 链接目标覆盖 -> `link_target_coverage_report.sql`
|
||||
|
||||
Aftersale special rule:
|
||||
- Do not analyze only one table.
|
||||
- Must prioritize reason dimensions from:
|
||||
1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56)
|
||||
2) `pc_kfChatLog` (mainReason/secondReason/thirdReason)
|
||||
3) `fys_Ticketing_install` (工单维度)
|
||||
- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions.
|
||||
|
||||
Metric priority rule (very important):
|
||||
- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`.
|
||||
- Amount metrics are secondary and optional.
|
||||
- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first.
|
||||
|
||||
Output format for sales/aftersale:
|
||||
1. 件数与订单核心结论
|
||||
2. 原因结构(一级/二级/三级)
|
||||
3. 趋势与异常
|
||||
4. 金额补充(可选)
|
||||
5. 可执行建议
|
||||
6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术”
|
||||
7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)”
|
||||
|
||||
### C. Market Analysis Tasks (non-sales)
|
||||
|
||||
Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析
|
||||
|
||||
Hard rules:
|
||||
- Only allow domain `hq.yucekj.com` / `yucekj.com`.
|
||||
- Use `exec` with `yuce-browser` only.
|
||||
- Never use browser `search` action.
|
||||
- Never use web_search/web_fetch for this route.
|
||||
- If login/CAPTCHA blocks, ask user for that exact step only.
|
||||
|
||||
### D. General Online Search Tasks
|
||||
|
||||
Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新
|
||||
|
||||
Preferred execution:
|
||||
1. First use `exec: web-search "<query>" --count 8` (stable path; includes Brave+Bing fallback).
|
||||
2. Optional: if user explicitly asks for Brave-only, use `web_search`.
|
||||
3. Summarize with source links, and mark date/time context when content is time-sensitive.
|
||||
|
||||
## 3) Security and Privacy
|
||||
|
||||
- Do not expose raw data source endpoints in user-facing replies.
|
||||
- Mask domain/IP/api endpoints in final messages when possible.
|
||||
- Exception: when reporting Feishu push result, must show the actual Feishu table link (do not mask `*.feishu.cn`).
|
||||
- Never leak secrets from env files.
|
||||
|
||||
## 4) Reply Quality Rules
|
||||
|
||||
- Send concise final answer; no long tool transcript dump.
|
||||
- If task may take time, send one short processing hint first.
|
||||
- Do not repeat the same result twice in one turn.
|
||||
- If a request fails, include: what was attempted, what failed, and next actionable fix.
|
||||
|
||||
## 5) Feishu Delivery Rules
|
||||
|
||||
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
|
||||
|
||||
Default target (pinned):
|
||||
- default_link: https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI
|
||||
- default_app_token: SZ4ebCKU3a9OdfspetxcSeg1n4g
|
||||
- default_table_id: tblzuMy85WiRAUX5
|
||||
|
||||
Required behavior:
|
||||
- Never auto-push to Feishu unless the user explicitly asks in the current turn.
|
||||
- If current turn does not contain Feishu push intent, do analysis only and return in chat.
|
||||
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
|
||||
- For stock-risk/inventory warning reports, do NOT write a long text blob into one cell; must push structured columns/rows (summary row + detail rows), preferably via `exec: stock-risk-to-feishu --days 30 --top 100`.
|
||||
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
|
||||
- If user does not provide a link in current turn, ALWAYS use the pinned default target above.
|
||||
- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table.
|
||||
- In final reply, always include:
|
||||
1) push status (success/failed)
|
||||
2) target sheet link (actual link used)
|
||||
3) written record_id (or updated count)
|
||||
- If write partially fails, return succeeded records and failed reason list separately.
|
||||
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
|
||||
|
||||
## 6) Workspace Paths
|
||||
|
||||
- SQL templates: `/root/.openclaw/workspace/sqlserver/`
|
||||
- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
|
||||
- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md`
|
||||
- Tool guide: `/root/.openclaw/workspace/TOOLS.md`
|
||||
- Site config: `/root/.openclaw/workspace/websites.yaml`
|
||||
101
README_SQL_TEMPLATES.md
Normal file
101
README_SQL_TEMPLATES.md
Normal file
@ -0,0 +1,101 @@
|
||||
# 销售分析常用 SQL 模板包
|
||||
|
||||
数据源:`JSDataMiddlePlatform`(SQLServer)
|
||||
执行方式:`company-db run-file <sql文件路径>`
|
||||
|
||||
默认分析口径:
|
||||
- 主口径优先:`发货件数 / 售后件数 / 订单数`
|
||||
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
|
||||
|
||||
## 模板列表
|
||||
|
||||
1. `shop_weekly_report.sql`
|
||||
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
2. `sku_structure_report.sql`
|
||||
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
|
||||
- 默认时间:最近30天
|
||||
|
||||
3. `refund_reason_report.sql`
|
||||
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
|
||||
- 默认时间:最近30天
|
||||
|
||||
4. `fee_ratio_alert.sql`
|
||||
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
|
||||
- 默认时间:最近14天
|
||||
|
||||
5. `logistics_sla_report.sql`
|
||||
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
|
||||
- 默认时间:最近30天
|
||||
|
||||
6. `freight_efficiency_report.sql`
|
||||
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
|
||||
- 默认时间:最近30天
|
||||
|
||||
7. `install_service_quality_report.sql`
|
||||
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
|
||||
- 默认时间:最近90天
|
||||
|
||||
8. `customer_service_hotspot_report.sql`
|
||||
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
|
||||
- 默认时间:最近30天
|
||||
|
||||
9. `link_target_coverage_report.sql`
|
||||
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
|
||||
- 默认时间:在线链接全量
|
||||
|
||||
10. `after_sale_360_report.sql`
|
||||
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
11. `product-info`(CLI辅助命令)
|
||||
- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警
|
||||
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 输出:
|
||||
- 结果集1:产品基础信息(SKU名称、成本、材质、预览图等)
|
||||
- 结果集2:最近N天出货/售后件数
|
||||
- 结果集3:最近N天店铺分布(件数)
|
||||
- 结果集4:最近N天一区/二区/三区运费汇总
|
||||
- 结果集5:最近N天省份运费TOP
|
||||
- 结果集6:最近N天售后总览
|
||||
- 结果集7:最近N天售后类型/结果矩阵
|
||||
- 结果集8:最近N天售后原因TOP
|
||||
- 结果集9:最近N天每日总发货件数(按SKU)
|
||||
- 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)
|
||||
- 结果集11:近30天在售商品ID数量(按店铺渠道)
|
||||
- 结果集12:商品ID对应店铺名/渠道/运营人(通知清单)
|
||||
- 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营)
|
||||
|
||||
12. `product-skus-by-id`(CLI辅助命令)
|
||||
- 用途:按商品ID查询该ID下所有SKU编码与SKU名称
|
||||
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
|
||||
|
||||
13. `customer_service_experience_report.sql`
|
||||
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
|
||||
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
|
||||
|
||||
14. `customer-service-analysis`(CLI辅助命令)
|
||||
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
|
||||
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
|
||||
15. `sku_stock_risk_report.sql`
|
||||
- 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
|
||||
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
|
||||
- 参数:修改模板顶部 `@sku` 即可
|
||||
|
||||
## 运行示例
|
||||
|
||||
```bash
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql
|
||||
product-info "CJ019317N1097V01" --days 30 --limit 20
|
||||
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有模板默认过滤 `配件` 和 `特殊单` 标签。
|
||||
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
|
||||
- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。
|
||||
113
README_SQL_TEMPLATES.remote.live.md
Normal file
113
README_SQL_TEMPLATES.remote.live.md
Normal file
@ -0,0 +1,113 @@
|
||||
# 销售分析常用 SQL 模板包
|
||||
|
||||
数据源:`JSDataMiddlePlatform`(SQLServer)
|
||||
执行方式:`company-db run-file <sql文件路径>`
|
||||
|
||||
默认分析口径:
|
||||
- 主口径优先:`发货件数 / 售后件数 / 订单数`
|
||||
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
|
||||
|
||||
## 模板列表
|
||||
|
||||
1. `shop_weekly_report.sql`
|
||||
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
2. `sku_structure_report.sql`
|
||||
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
|
||||
- 默认时间:最近30天
|
||||
|
||||
3. `refund_reason_report.sql`
|
||||
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
|
||||
- 默认时间:最近30天
|
||||
|
||||
4. `fee_ratio_alert.sql`
|
||||
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
|
||||
- 默认时间:最近14天
|
||||
|
||||
5. `logistics_sla_report.sql`
|
||||
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
|
||||
- 默认时间:最近30天
|
||||
|
||||
6. `freight_efficiency_report.sql`
|
||||
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
|
||||
- 默认时间:最近30天
|
||||
|
||||
7. `install_service_quality_report.sql`
|
||||
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
|
||||
- 默认时间:最近90天
|
||||
|
||||
8. `customer_service_hotspot_report.sql`
|
||||
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
|
||||
- 默认时间:最近30天
|
||||
|
||||
9. `link_target_coverage_report.sql`
|
||||
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
|
||||
- 默认时间:在线链接全量
|
||||
|
||||
10. `after_sale_360_report.sql`
|
||||
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
11. `product-info`(CLI辅助命令)
|
||||
- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警
|
||||
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 输出:
|
||||
- 结果集1:产品基础信息(SKU名称、成本、材质、预览图等)
|
||||
- 结果集2:最近N天出货/售后件数
|
||||
- 结果集3:最近N天店铺分布(件数)
|
||||
- 结果集4:最近N天一区/二区/三区运费汇总
|
||||
- 结果集5:最近N天省份运费TOP
|
||||
- 结果集6:最近N天售后总览
|
||||
- 结果集7:最近N天售后类型/结果矩阵
|
||||
- 结果集8:最近N天售后原因TOP
|
||||
- 结果集9:最近N天每日总发货件数(按SKU)
|
||||
- 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)
|
||||
- 结果集11:近30天在售商品ID数量(按店铺渠道)
|
||||
- 结果集12:商品ID对应店铺名/渠道/运营人(通知清单)
|
||||
- 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营)
|
||||
|
||||
12. `product-skus-by-id`(CLI辅助命令)
|
||||
- 用途:按商品ID查询该ID下所有SKU编码与SKU名称
|
||||
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
|
||||
|
||||
13. `customer_service_experience_report.sql`
|
||||
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
|
||||
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
|
||||
|
||||
14. `customer-service-analysis`(CLI辅助命令)
|
||||
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
|
||||
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
|
||||
15. `sku_stock_risk_report.sql`
|
||||
- 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
|
||||
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
|
||||
- 参数:修改模板顶部 `@sku` 即可
|
||||
|
||||
16. `stock-risk-alerts`(CLI辅助命令)
|
||||
- 用途:查询近30天所有在售SKU的缺货预警明细(SKU编码维度)
|
||||
- 命令:`stock-risk-alerts --days 30 --limit 500`
|
||||
- 输出:
|
||||
- 结果集1:全量在售SKU/风险SKU汇总
|
||||
- 结果集2:风险明细(预计缺货天数、补货建议、采购、工厂、仓库、运营、渠道/店铺)
|
||||
- 常用参数:
|
||||
- `--lead-days 10`(补货提前期)
|
||||
- `--warn-days 15`(预警阈值)
|
||||
- `--include-safe`(包含安全SKU)
|
||||
|
||||
## 运行示例
|
||||
|
||||
```bash
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql
|
||||
product-info "CJ019317N1097V01" --days 30 --limit 20
|
||||
stock-risk-alerts --days 30 --limit 500
|
||||
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有模板默认过滤 `配件` 和 `特殊单` 标签。
|
||||
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
|
||||
- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。
|
||||
90
TOOLS.remote.live.md
Normal file
90
TOOLS.remote.live.md
Normal file
@ -0,0 +1,90 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
|
||||
|
||||
## What Goes Here
|
||||
|
||||
Things like:
|
||||
|
||||
- Camera names and locations
|
||||
- SSH hosts and aliases
|
||||
- Preferred voices for TTS
|
||||
- Speaker/room names
|
||||
- Device nicknames
|
||||
- Anything environment-specific
|
||||
|
||||
## Examples
|
||||
|
||||
```markdown
|
||||
### Cameras
|
||||
|
||||
- living-room → Main area, 180° wide angle
|
||||
- front-door → Entrance, motion-triggered
|
||||
|
||||
### SSH
|
||||
|
||||
- home-server → 192.168.1.100, user: admin
|
||||
|
||||
### TTS
|
||||
|
||||
- Preferred voice: "Nova" (warm, slightly British)
|
||||
- Default speaker: Kitchen HomePod
|
||||
```
|
||||
|
||||
## Why Separate?
|
||||
|
||||
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
|
||||
|
||||
---
|
||||
|
||||
Add whatever helps you do your job. This is your cheat sheet.
|
||||
|
||||
## Web Routing Preset (Auto)
|
||||
|
||||
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
|
||||
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
|
||||
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
|
||||
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
|
||||
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
|
||||
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
|
||||
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
|
||||
- If task does not need login, use `public-search` or direct public site access.
|
||||
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
|
||||
- Never operate outside `allowed_domains`.
|
||||
|
||||
## Product Info Helper
|
||||
|
||||
- Command: `product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- Returns:
|
||||
1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)
|
||||
2) Last N days shipped/aftersale counts
|
||||
3) Last N days shop distribution by shipped piece count
|
||||
4) Last N days daily shipped pieces by SKU
|
||||
5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`)
|
||||
6) Last 30d sold product-id counts by shop channel
|
||||
7) Product-id to shop/operator mapping (for notification)
|
||||
8) Risk-only contact list (`purchaser_name`, `factory_name`, `warehouse_name`, `operator_name`)
|
||||
- Data source: SQLServer `JSDataMiddlePlatform`
|
||||
- Command: `product-skus-by-id "<商品id>" --limit 1000`
|
||||
- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid).
|
||||
- `product-info` now also returns aftersale insight for matched SKU:
|
||||
- aftersale summary, type/result matrix, reason TOP (last N days)
|
||||
- Command: `stock-risk-alerts --days 30 --limit 500`
|
||||
- Purpose: Query all selling SKUs with potential stockout warning details.
|
||||
- Output:
|
||||
1) summary counters (`sold_sku_count_30d`, `stock_risk_sku_count`, `urgent_sku_count`, `warning_sku_count`)
|
||||
2) detailed risk list with `sku_id`, `estimated_stockout_days`, `suggest_replenish_qty_to_lead_days`, `purchaser_name`, `factory_name`, `warehouse_name`, `operator_name`
|
||||
- Command: `stock-risk-to-feishu --days 30 --top 100`
|
||||
- Purpose: Push stock risk report to Feishu in structured rows (not one-cell long text).
|
||||
- Output:
|
||||
- summary record + detail records, with batch id and Feishu link
|
||||
- columns include `SKU编码/SKU名称/风险等级/预计缺货天数/建议补货件数/采购/运营/工厂/仓库/渠道/店铺`
|
||||
|
||||
|
||||
## Customer Service Experience Helper
|
||||
|
||||
- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
- Purpose: Analyze customer feedback experience + service wording quality in a date range.
|
||||
- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D).
|
||||
|
||||
- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.
|
||||
BIN
__pycache__/openclaw-log-observercpython-313.pyc
Normal file
BIN
__pycache__/openclaw-log-observercpython-313.pyc
Normal file
Binary file not shown.
650
bot.bridge.live.py
Executable file
650
bot.bridge.live.py
Executable file
@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
|
||||
from dingtalk_stream import Credential, DingTalkStreamClient
|
||||
|
||||
|
||||
MARKET_HINT_KEYWORDS = (
|
||||
"市场分析",
|
||||
"市场数据分析",
|
||||
"行业分析",
|
||||
"类目分析",
|
||||
"竞品分析",
|
||||
"趋势分析",
|
||||
)
|
||||
|
||||
SALES_HINT_KEYWORDS = (
|
||||
"销售数据",
|
||||
"销售报表",
|
||||
"销售看板",
|
||||
"公司销售",
|
||||
"业绩报表",
|
||||
"业绩数据",
|
||||
"费比监控",
|
||||
)
|
||||
|
||||
SENSITIVE_SALES_KEYWORDS = (
|
||||
"销售",
|
||||
"业绩",
|
||||
"售后",
|
||||
"退款",
|
||||
"店铺",
|
||||
"发货",
|
||||
"工单",
|
||||
"费比",
|
||||
)
|
||||
|
||||
EXPLICIT_AMOUNT_KEYWORDS = (
|
||||
"金额",
|
||||
"费用",
|
||||
"收入",
|
||||
"利润",
|
||||
"成本",
|
||||
"客单价",
|
||||
"gmv",
|
||||
"销售额",
|
||||
"退款额",
|
||||
"毛利",
|
||||
)
|
||||
|
||||
|
||||
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
|
||||
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
|
||||
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
|
||||
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
|
||||
|
||||
|
||||
|
||||
def bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class OpenClawDingTalkBot(ChatbotHandler):
|
||||
def __init__(self, logger: logging.Logger):
|
||||
super().__init__()
|
||||
self.logger = logger
|
||||
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
|
||||
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
|
||||
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
|
||||
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
|
||||
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
|
||||
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
|
||||
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
|
||||
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
|
||||
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
|
||||
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
|
||||
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
|
||||
self.unmask_url_hosts = {
|
||||
h.strip().lower()
|
||||
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
|
||||
if h.strip()
|
||||
}
|
||||
self._recent_event_ts = {}
|
||||
self.processing_hint_text = os.getenv(
|
||||
"PROCESSING_HINT_TEXT",
|
||||
"已收到,正在处理你的需求,请稍等1-3分钟。",
|
||||
).strip()
|
||||
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
|
||||
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
|
||||
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
|
||||
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
|
||||
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
|
||||
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
|
||||
|
||||
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||
if text.startswith(self.prefix):
|
||||
return True
|
||||
if msg.is_in_at_list:
|
||||
return True
|
||||
return not self.require_prefix
|
||||
|
||||
def _normalize_prompt(self, text: str) -> str:
|
||||
text = text.strip()
|
||||
if self.prefix and text.startswith(self.prefix):
|
||||
text = text[len(self.prefix):].strip()
|
||||
text = re.sub(r"^@\S+\s*", "", text).strip()
|
||||
return text
|
||||
|
||||
def _session_id(self, msg: ChatbotMessage) -> str:
|
||||
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
|
||||
base_session = f"{self.session_prefix}-{conversation_id}"
|
||||
|
||||
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
|
||||
if self.session_max_lines > 0 and os.path.exists(session_file):
|
||||
try:
|
||||
with open(session_file, "rb") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
if line_count >= self.session_max_lines:
|
||||
rotate_suffix = time.strftime("%Y%m%d%H%M")
|
||||
rotated = f"{base_session}-{rotate_suffix}"
|
||||
self.logger.info(
|
||||
"session auto-rotated: base=%s lines=%s rotated=%s",
|
||||
base_session,
|
||||
line_count,
|
||||
rotated,
|
||||
)
|
||||
return rotated
|
||||
except Exception:
|
||||
self.logger.exception("failed to inspect session file: %s", session_file)
|
||||
|
||||
return base_session
|
||||
|
||||
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
|
||||
if getattr(msg, "message_id", None):
|
||||
return f"msg:{msg.message_id}"
|
||||
|
||||
data = callback.data if isinstance(callback.data, dict) else {}
|
||||
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return f"data:{key}:{value}"
|
||||
|
||||
headers = getattr(callback, "headers", {}) or {}
|
||||
for key in ("messageId", "eventId", "processQueryKey"):
|
||||
value = headers.get(key)
|
||||
if value:
|
||||
return f"hdr:{key}:{value}"
|
||||
|
||||
raw = "|".join(
|
||||
[
|
||||
str(getattr(msg, "conversation_id", "") or ""),
|
||||
str(getattr(msg, "sender_id", "") or ""),
|
||||
str(getattr(msg, "sender_staff_id", "") or ""),
|
||||
str(getattr(msg, "create_at", "") or ""),
|
||||
prompt[:200],
|
||||
]
|
||||
)
|
||||
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _seen_or_mark_event(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
expire_before = now - max(self.dedupe_ttl_seconds, 30)
|
||||
|
||||
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
|
||||
for stale_key in stale_keys:
|
||||
self._recent_event_ts.pop(stale_key, None)
|
||||
|
||||
if key in self._recent_event_ts:
|
||||
return True
|
||||
|
||||
self._recent_event_ts[key] = now
|
||||
return False
|
||||
|
||||
def _is_market_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt)
|
||||
|
||||
if any(k in normalized for k in SALES_HINT_KEYWORDS):
|
||||
return False
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return True
|
||||
|
||||
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
|
||||
return True
|
||||
|
||||
if "市场" in normalized and "分析" in normalized:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_sales_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
|
||||
|
||||
def _explicit_amount_requested(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
|
||||
|
||||
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
|
||||
if not self._is_sales_prompt(prompt):
|
||||
return answer
|
||||
if self._explicit_amount_requested(prompt):
|
||||
return answer
|
||||
|
||||
filtered_lines = []
|
||||
removed = 0
|
||||
for line in answer.splitlines():
|
||||
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
|
||||
removed += 1
|
||||
continue
|
||||
filtered_lines.append(line)
|
||||
|
||||
cleaned = "\n".join(filtered_lines).strip()
|
||||
if removed > 0:
|
||||
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
|
||||
if cleaned:
|
||||
cleaned = f"{cleaned}\n\n{note}"
|
||||
else:
|
||||
cleaned = note
|
||||
|
||||
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
|
||||
|
||||
def _extract_market_subject(self, prompt: str) -> str:
|
||||
normalized = prompt.strip()
|
||||
|
||||
def clean_subject(raw: str) -> str:
|
||||
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
|
||||
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
|
||||
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
|
||||
return v.strip()
|
||||
|
||||
def pick(raw: str):
|
||||
v = clean_subject(raw)
|
||||
if len(v) < 2:
|
||||
return None
|
||||
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
|
||||
return None
|
||||
return v
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return "电脑桌"
|
||||
|
||||
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
return "电脑桌"
|
||||
|
||||
def _sanitize_answer(self, answer: str) -> str:
|
||||
if not self.hide_source_urls:
|
||||
return answer
|
||||
|
||||
keep_map = {}
|
||||
|
||||
def _keep_or_mask_url(match):
|
||||
raw = match.group(0)
|
||||
try:
|
||||
host = (urllib.parse.urlparse(raw).hostname or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
|
||||
if host and (
|
||||
host in self.unmask_url_hosts
|
||||
or host == "feishu.cn"
|
||||
or host.endswith(".feishu.cn")
|
||||
):
|
||||
token = f"__KEEP_URL_{len(keep_map)}__"
|
||||
keep_map[token] = raw
|
||||
return token
|
||||
|
||||
return "[内部数据源地址已脱敏]"
|
||||
|
||||
out = URL_RE.sub(_keep_or_mask_url, answer)
|
||||
out = IP_RE.sub("[内部地址已脱敏]", out)
|
||||
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
|
||||
|
||||
# 保底脱敏(防止某些格式未被正则覆盖)
|
||||
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
|
||||
out = out.replace(token, "[内部数据源已脱敏]")
|
||||
|
||||
for token, raw in keep_map.items():
|
||||
out = out.replace(token, raw)
|
||||
|
||||
return out
|
||||
|
||||
def _run_market_brief(self, subject: str) -> str:
|
||||
cmd = [self.market_brief_bin, subject]
|
||||
self.logger.info("market-brief call: subject=%s", subject)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.market_brief_timeout,
|
||||
)
|
||||
output = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or output or "market brief error").strip()
|
||||
return f"市场分析执行失败: {err[:500]}"
|
||||
if not output:
|
||||
return "市场分析执行完成,但未返回文本结果。"
|
||||
return output
|
||||
|
||||
def _run_openclaw(self, session_id: str, prompt: str) -> str:
|
||||
cmd = [
|
||||
self.openclaw_bin,
|
||||
"agent",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(self.openclaw_timeout),
|
||||
]
|
||||
self.logger.info("openclaw call: session=%s", session_id)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.openclaw_timeout + 20,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
|
||||
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw error").strip()
|
||||
return f"OpenClaw 调用失败: {err[:500]}"
|
||||
|
||||
try:
|
||||
raw_stdout = (proc.stdout or "").strip()
|
||||
decoder = json.JSONDecoder()
|
||||
data = None
|
||||
parse_error = None
|
||||
try:
|
||||
data, _ = decoder.raw_decode(raw_stdout)
|
||||
except Exception as exc:
|
||||
parse_error = exc
|
||||
idx = raw_stdout.find("{")
|
||||
while idx != -1:
|
||||
candidate = raw_stdout[idx:]
|
||||
try:
|
||||
data, _ = decoder.raw_decode(candidate)
|
||||
break
|
||||
except Exception:
|
||||
idx = raw_stdout.find("{", idx + 1)
|
||||
if data is None:
|
||||
raise parse_error
|
||||
|
||||
payloads = data.get("result", {}).get("payloads", [])
|
||||
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
|
||||
answer = "\n".join(t for t in text_items if t).strip()
|
||||
if not answer:
|
||||
answer = "任务已执行,但未返回文本结果。"
|
||||
|
||||
if self._is_market_prompt(prompt) and (
|
||||
"无法直接从页面抓取" in answer
|
||||
or "需要登录后的操作" in answer
|
||||
or "工具版本不支持点击交互" in answer
|
||||
):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
fallback = self._run_market_brief(subject)
|
||||
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
|
||||
|
||||
return answer
|
||||
except Exception as exc:
|
||||
self.logger.exception("parse openclaw output failed")
|
||||
raw = (proc.stdout or "").strip()
|
||||
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
||||
|
||||
def _is_image_url(self, raw_url: str) -> bool:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(raw_url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = (parsed.path or "").lower()
|
||||
if IMAGE_EXT_RE.search(path):
|
||||
return True
|
||||
if host.startswith("img.") or "image" in host:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
|
||||
urls = []
|
||||
seen = set()
|
||||
for match in URL_RE.finditer(text or ""):
|
||||
raw = match.group(0).rstrip(".,;!?")
|
||||
if not self._is_image_url(raw):
|
||||
continue
|
||||
if raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
urls.append(raw)
|
||||
if len(urls) >= limit:
|
||||
break
|
||||
return urls
|
||||
|
||||
def _remove_image_urls(self, text: str, urls: list) -> str:
|
||||
out = text
|
||||
for u in urls:
|
||||
out = out.replace(u, "")
|
||||
out = re.sub(r"\n{3,}", "\n\n", out)
|
||||
return out.strip()
|
||||
|
||||
def _build_action_buttons(self) -> list:
|
||||
if not self.action_card_btn_url:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"text": self.action_card_btn_text or "打开链接",
|
||||
"url": self.action_card_btn_url,
|
||||
"iosUrl": self.action_card_btn_url,
|
||||
"color": "gray",
|
||||
}
|
||||
]
|
||||
|
||||
def _is_card_reply_ok(self, reply_obj) -> bool:
|
||||
if reply_obj is None:
|
||||
return False
|
||||
card_instance_id = getattr(reply_obj, "card_instance_id", None)
|
||||
if isinstance(card_instance_id, str):
|
||||
return bool(card_instance_id.strip())
|
||||
return bool(card_instance_id)
|
||||
|
||||
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool:
|
||||
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
|
||||
if prefer_card and self.reply_style == "action_card":
|
||||
image_urls = self._extract_image_urls(payload, limit=5)
|
||||
if image_urls:
|
||||
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
|
||||
markdown = self._remove_image_urls(payload, image_urls)
|
||||
if not markdown:
|
||||
markdown = "已整理相关图片如下:"
|
||||
try:
|
||||
card = self.reply_carousel_card(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
image_slider=slider,
|
||||
button_text=self.action_card_btn_text or "查看",
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
if self._is_card_reply_ok(card):
|
||||
return True
|
||||
self.logger.warning("carousel card reply returned empty id, fallback to action card/text")
|
||||
except Exception:
|
||||
self.logger.exception("carousel card reply failed, fallback to action card")
|
||||
|
||||
markdown = payload
|
||||
if self.action_card_title:
|
||||
markdown = f"### {self.action_card_title}\n\n{payload}"
|
||||
try:
|
||||
card = self.reply_markdown_button(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
button_list=self._build_action_buttons(),
|
||||
tips=self.action_card_tips,
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
if self._is_card_reply_ok(card):
|
||||
return True
|
||||
self.logger.warning("action card reply returned empty id, fallback to text")
|
||||
except Exception:
|
||||
self.logger.exception("action card reply failed, fallback to text")
|
||||
resp = self.reply_text(payload, msg)
|
||||
if resp is None:
|
||||
self.logger.error("text reply failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def process(self, callback: CallbackMessage):
|
||||
try:
|
||||
msg = ChatbotMessage.from_dict(callback.data)
|
||||
except Exception:
|
||||
self.logger.exception("invalid callback payload")
|
||||
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
|
||||
|
||||
text = ""
|
||||
if msg.text and getattr(msg.text, "content", None):
|
||||
text = msg.text.content.strip()
|
||||
else:
|
||||
text_list = msg.get_text_list() or []
|
||||
text = text_list[0].strip() if text_list else ""
|
||||
|
||||
if not text:
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
if not self._should_handle(msg, text):
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
prompt = self._normalize_prompt(text)
|
||||
if not prompt:
|
||||
self._reply_message(
|
||||
msg,
|
||||
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
|
||||
prefer_card=False,
|
||||
)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
event_key = self._event_key(callback, msg, prompt)
|
||||
if self._seen_or_mark_event(event_key):
|
||||
self.logger.info(
|
||||
"duplicate callback ignored: key=%s conversation=%s sender=%s",
|
||||
event_key,
|
||||
getattr(msg, "conversation_id", ""),
|
||||
getattr(msg, "sender_id", ""),
|
||||
)
|
||||
return AckMessage.STATUS_OK, "duplicate_ignored"
|
||||
|
||||
if self.send_processing_hint:
|
||||
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
|
||||
try:
|
||||
self.reply_text(hint, msg)
|
||||
except Exception:
|
||||
self.logger.exception("failed to send processing hint")
|
||||
|
||||
session_id = self._session_id(msg)
|
||||
if self._is_market_prompt(prompt):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
answer = self._run_market_brief(subject)
|
||||
else:
|
||||
answer = self._run_openclaw(session_id, prompt)
|
||||
|
||||
answer = self._sanitize_answer(answer)
|
||||
answer = self._mask_sensitive_amounts(prompt, answer)
|
||||
if len(answer) > self.reply_max_chars:
|
||||
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
|
||||
|
||||
try:
|
||||
sent = self._reply_message(msg, answer, prefer_card=True)
|
||||
if sent:
|
||||
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
|
||||
else:
|
||||
self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer))
|
||||
except Exception:
|
||||
self.logger.exception("failed to send final reply: session=%s", session_id)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
|
||||
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
|
||||
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
|
||||
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
|
||||
|
||||
credential = Credential(client_id, client_secret)
|
||||
client = DingTalkStreamClient(credential, logger=logger)
|
||||
bot = OpenClawDingTalkBot(logger)
|
||||
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
|
||||
if bool_env("REGISTER_DELEGATE_TOPIC", False):
|
||||
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
|
||||
logger.info("delegate topic enabled")
|
||||
else:
|
||||
logger.info("delegate topic disabled (default)")
|
||||
return client
|
||||
|
||||
|
||||
def self_check(logger: logging.Logger) -> int:
|
||||
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
|
||||
missing = [k for k in required if not os.getenv(k)]
|
||||
if missing:
|
||||
logger.error("missing env: %s", ", ".join(missing))
|
||||
return 2
|
||||
|
||||
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
|
||||
return 3
|
||||
|
||||
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
if not os.path.exists(market_brief_bin):
|
||||
logger.error("market brief bin not found: %s", market_brief_bin)
|
||||
return 4
|
||||
|
||||
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
|
||||
parser.add_argument("--check", action="store_true", help="run checks and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("openclaw-dingtalk-bridge")
|
||||
|
||||
if args.check:
|
||||
return self_check(logger)
|
||||
|
||||
client = build_client(logger)
|
||||
logger.info("bridge started, listening DingTalk stream")
|
||||
client.start_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
627
bot.py.restore
Executable file
627
bot.py.restore
Executable file
@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
|
||||
from dingtalk_stream import Credential, DingTalkStreamClient
|
||||
|
||||
|
||||
MARKET_HINT_KEYWORDS = (
|
||||
"市场分析",
|
||||
"市场数据分析",
|
||||
"行业分析",
|
||||
"类目分析",
|
||||
"竞品分析",
|
||||
"趋势分析",
|
||||
)
|
||||
|
||||
SALES_HINT_KEYWORDS = (
|
||||
"销售数据",
|
||||
"销售报表",
|
||||
"销售看板",
|
||||
"公司销售",
|
||||
"业绩报表",
|
||||
"业绩数据",
|
||||
"费比监控",
|
||||
)
|
||||
|
||||
SENSITIVE_SALES_KEYWORDS = (
|
||||
"销售",
|
||||
"业绩",
|
||||
"售后",
|
||||
"退款",
|
||||
"店铺",
|
||||
"发货",
|
||||
"工单",
|
||||
"费比",
|
||||
)
|
||||
|
||||
EXPLICIT_AMOUNT_KEYWORDS = (
|
||||
"金额",
|
||||
"费用",
|
||||
"收入",
|
||||
"利润",
|
||||
"成本",
|
||||
"客单价",
|
||||
"gmv",
|
||||
"销售额",
|
||||
"退款额",
|
||||
"毛利",
|
||||
)
|
||||
|
||||
|
||||
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
|
||||
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
|
||||
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
|
||||
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
|
||||
|
||||
|
||||
|
||||
def bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class OpenClawDingTalkBot(ChatbotHandler):
|
||||
def __init__(self, logger: logging.Logger):
|
||||
super().__init__()
|
||||
self.logger = logger
|
||||
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
|
||||
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
|
||||
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
|
||||
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
|
||||
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
|
||||
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
|
||||
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
|
||||
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
|
||||
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
|
||||
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
|
||||
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
|
||||
self.unmask_url_hosts = {
|
||||
h.strip().lower()
|
||||
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
|
||||
if h.strip()
|
||||
}
|
||||
self._recent_event_ts = {}
|
||||
self.processing_hint_text = os.getenv(
|
||||
"PROCESSING_HINT_TEXT",
|
||||
"已收到,正在处理你的需求,请稍等1-3分钟。",
|
||||
).strip()
|
||||
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
|
||||
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
|
||||
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
|
||||
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
|
||||
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
|
||||
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
|
||||
|
||||
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||
if text.startswith(self.prefix):
|
||||
return True
|
||||
if msg.is_in_at_list:
|
||||
return True
|
||||
return not self.require_prefix
|
||||
|
||||
def _normalize_prompt(self, text: str) -> str:
|
||||
text = text.strip()
|
||||
if self.prefix and text.startswith(self.prefix):
|
||||
text = text[len(self.prefix):].strip()
|
||||
text = re.sub(r"^@\S+\s*", "", text).strip()
|
||||
return text
|
||||
|
||||
def _session_id(self, msg: ChatbotMessage) -> str:
|
||||
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
|
||||
base_session = f"{self.session_prefix}-{conversation_id}"
|
||||
|
||||
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
|
||||
if self.session_max_lines > 0 and os.path.exists(session_file):
|
||||
try:
|
||||
with open(session_file, "rb") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
if line_count >= self.session_max_lines:
|
||||
rotate_suffix = time.strftime("%Y%m%d%H%M")
|
||||
rotated = f"{base_session}-{rotate_suffix}"
|
||||
self.logger.info(
|
||||
"session auto-rotated: base=%s lines=%s rotated=%s",
|
||||
base_session,
|
||||
line_count,
|
||||
rotated,
|
||||
)
|
||||
return rotated
|
||||
except Exception:
|
||||
self.logger.exception("failed to inspect session file: %s", session_file)
|
||||
|
||||
return base_session
|
||||
|
||||
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
|
||||
if getattr(msg, "message_id", None):
|
||||
return f"msg:{msg.message_id}"
|
||||
|
||||
data = callback.data if isinstance(callback.data, dict) else {}
|
||||
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return f"data:{key}:{value}"
|
||||
|
||||
headers = getattr(callback, "headers", {}) or {}
|
||||
for key in ("messageId", "eventId", "processQueryKey"):
|
||||
value = headers.get(key)
|
||||
if value:
|
||||
return f"hdr:{key}:{value}"
|
||||
|
||||
raw = "|".join(
|
||||
[
|
||||
str(getattr(msg, "conversation_id", "") or ""),
|
||||
str(getattr(msg, "sender_id", "") or ""),
|
||||
str(getattr(msg, "sender_staff_id", "") or ""),
|
||||
str(getattr(msg, "create_at", "") or ""),
|
||||
prompt[:200],
|
||||
]
|
||||
)
|
||||
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _seen_or_mark_event(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
expire_before = now - max(self.dedupe_ttl_seconds, 30)
|
||||
|
||||
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
|
||||
for stale_key in stale_keys:
|
||||
self._recent_event_ts.pop(stale_key, None)
|
||||
|
||||
if key in self._recent_event_ts:
|
||||
return True
|
||||
|
||||
self._recent_event_ts[key] = now
|
||||
return False
|
||||
|
||||
def _is_market_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt)
|
||||
|
||||
if any(k in normalized for k in SALES_HINT_KEYWORDS):
|
||||
return False
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return True
|
||||
|
||||
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
|
||||
return True
|
||||
|
||||
if "市场" in normalized and "分析" in normalized:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_sales_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
|
||||
|
||||
def _explicit_amount_requested(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
|
||||
|
||||
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
|
||||
if not self._is_sales_prompt(prompt):
|
||||
return answer
|
||||
if self._explicit_amount_requested(prompt):
|
||||
return answer
|
||||
|
||||
filtered_lines = []
|
||||
removed = 0
|
||||
for line in answer.splitlines():
|
||||
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
|
||||
removed += 1
|
||||
continue
|
||||
filtered_lines.append(line)
|
||||
|
||||
cleaned = "\n".join(filtered_lines).strip()
|
||||
if removed > 0:
|
||||
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
|
||||
if cleaned:
|
||||
cleaned = f"{cleaned}\n\n{note}"
|
||||
else:
|
||||
cleaned = note
|
||||
|
||||
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
|
||||
|
||||
def _extract_market_subject(self, prompt: str) -> str:
|
||||
normalized = prompt.strip()
|
||||
|
||||
def clean_subject(raw: str) -> str:
|
||||
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
|
||||
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
|
||||
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
|
||||
return v.strip()
|
||||
|
||||
def pick(raw: str):
|
||||
v = clean_subject(raw)
|
||||
if len(v) < 2:
|
||||
return None
|
||||
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
|
||||
return None
|
||||
return v
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return "电脑桌"
|
||||
|
||||
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
return "电脑桌"
|
||||
|
||||
def _sanitize_answer(self, answer: str) -> str:
|
||||
if not self.hide_source_urls:
|
||||
return answer
|
||||
|
||||
keep_map = {}
|
||||
|
||||
def _keep_or_mask_url(match):
|
||||
raw = match.group(0)
|
||||
try:
|
||||
host = (urllib.parse.urlparse(raw).hostname or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
|
||||
if host and host in self.unmask_url_hosts:
|
||||
token = f"__KEEP_URL_{len(keep_map)}__"
|
||||
keep_map[token] = raw
|
||||
return token
|
||||
|
||||
return "[内部数据源地址已脱敏]"
|
||||
|
||||
out = URL_RE.sub(_keep_or_mask_url, answer)
|
||||
out = IP_RE.sub("[内部地址已脱敏]", out)
|
||||
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
|
||||
|
||||
# 保底脱敏(防止某些格式未被正则覆盖)
|
||||
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
|
||||
out = out.replace(token, "[内部数据源已脱敏]")
|
||||
|
||||
for token, raw in keep_map.items():
|
||||
out = out.replace(token, raw)
|
||||
|
||||
return out
|
||||
|
||||
def _run_market_brief(self, subject: str) -> str:
|
||||
cmd = [self.market_brief_bin, subject]
|
||||
self.logger.info("market-brief call: subject=%s", subject)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.market_brief_timeout,
|
||||
)
|
||||
output = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or output or "market brief error").strip()
|
||||
return f"市场分析执行失败: {err[:500]}"
|
||||
if not output:
|
||||
return "市场分析执行完成,但未返回文本结果。"
|
||||
return output
|
||||
|
||||
def _run_openclaw(self, session_id: str, prompt: str) -> str:
|
||||
cmd = [
|
||||
self.openclaw_bin,
|
||||
"agent",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(self.openclaw_timeout),
|
||||
]
|
||||
self.logger.info("openclaw call: session=%s", session_id)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.openclaw_timeout + 20,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
|
||||
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw error").strip()
|
||||
return f"OpenClaw 调用失败: {err[:500]}"
|
||||
|
||||
try:
|
||||
raw_stdout = (proc.stdout or "").strip()
|
||||
decoder = json.JSONDecoder()
|
||||
data = None
|
||||
parse_error = None
|
||||
try:
|
||||
data, _ = decoder.raw_decode(raw_stdout)
|
||||
except Exception as exc:
|
||||
parse_error = exc
|
||||
idx = raw_stdout.find("{")
|
||||
while idx != -1:
|
||||
candidate = raw_stdout[idx:]
|
||||
try:
|
||||
data, _ = decoder.raw_decode(candidate)
|
||||
break
|
||||
except Exception:
|
||||
idx = raw_stdout.find("{", idx + 1)
|
||||
if data is None:
|
||||
raise parse_error
|
||||
|
||||
payloads = data.get("result", {}).get("payloads", [])
|
||||
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
|
||||
answer = "\n".join(t for t in text_items if t).strip()
|
||||
if not answer:
|
||||
answer = "任务已执行,但未返回文本结果。"
|
||||
|
||||
if self._is_market_prompt(prompt) and (
|
||||
"无法直接从页面抓取" in answer
|
||||
or "需要登录后的操作" in answer
|
||||
or "工具版本不支持点击交互" in answer
|
||||
):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
fallback = self._run_market_brief(subject)
|
||||
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
|
||||
|
||||
return answer
|
||||
except Exception as exc:
|
||||
self.logger.exception("parse openclaw output failed")
|
||||
raw = (proc.stdout or "").strip()
|
||||
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
||||
|
||||
def _is_image_url(self, raw_url: str) -> bool:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(raw_url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = (parsed.path or "").lower()
|
||||
if IMAGE_EXT_RE.search(path):
|
||||
return True
|
||||
if host.startswith("img.") or "image" in host:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
|
||||
urls = []
|
||||
seen = set()
|
||||
for match in URL_RE.finditer(text or ""):
|
||||
raw = match.group(0).rstrip(".,;!?")
|
||||
if not self._is_image_url(raw):
|
||||
continue
|
||||
if raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
urls.append(raw)
|
||||
if len(urls) >= limit:
|
||||
break
|
||||
return urls
|
||||
|
||||
def _remove_image_urls(self, text: str, urls: list) -> str:
|
||||
out = text
|
||||
for u in urls:
|
||||
out = out.replace(u, "")
|
||||
out = re.sub(r"\n{3,}", "\n\n", out)
|
||||
return out.strip()
|
||||
|
||||
def _build_action_buttons(self) -> list:
|
||||
if not self.action_card_btn_url:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"text": self.action_card_btn_text or "打开链接",
|
||||
"url": self.action_card_btn_url,
|
||||
"iosUrl": self.action_card_btn_url,
|
||||
"color": "gray",
|
||||
}
|
||||
]
|
||||
|
||||
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True):
|
||||
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
|
||||
if prefer_card and self.reply_style == "action_card":
|
||||
image_urls = self._extract_image_urls(payload, limit=5)
|
||||
if image_urls:
|
||||
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
|
||||
markdown = self._remove_image_urls(payload, image_urls)
|
||||
if not markdown:
|
||||
markdown = "已整理相关图片如下:"
|
||||
try:
|
||||
self.reply_carousel_card(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
image_slider=slider,
|
||||
button_text=self.action_card_btn_text or "查看",
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
self.logger.exception("carousel card reply failed, fallback to action card")
|
||||
|
||||
markdown = payload
|
||||
if self.action_card_title:
|
||||
markdown = f"### {self.action_card_title}\n\n{payload}"
|
||||
try:
|
||||
self.reply_markdown_button(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
button_list=self._build_action_buttons(),
|
||||
tips=self.action_card_tips,
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
self.logger.exception("action card reply failed, fallback to text")
|
||||
self.reply_text(payload, msg)
|
||||
|
||||
async def process(self, callback: CallbackMessage):
|
||||
try:
|
||||
msg = ChatbotMessage.from_dict(callback.data)
|
||||
except Exception:
|
||||
self.logger.exception("invalid callback payload")
|
||||
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
|
||||
|
||||
text = ""
|
||||
if msg.text and getattr(msg.text, "content", None):
|
||||
text = msg.text.content.strip()
|
||||
else:
|
||||
text_list = msg.get_text_list() or []
|
||||
text = text_list[0].strip() if text_list else ""
|
||||
|
||||
if not text:
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
if not self._should_handle(msg, text):
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
prompt = self._normalize_prompt(text)
|
||||
if not prompt:
|
||||
self._reply_message(
|
||||
msg,
|
||||
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
|
||||
prefer_card=False,
|
||||
)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
event_key = self._event_key(callback, msg, prompt)
|
||||
if self._seen_or_mark_event(event_key):
|
||||
self.logger.info(
|
||||
"duplicate callback ignored: key=%s conversation=%s sender=%s",
|
||||
event_key,
|
||||
getattr(msg, "conversation_id", ""),
|
||||
getattr(msg, "sender_id", ""),
|
||||
)
|
||||
return AckMessage.STATUS_OK, "duplicate_ignored"
|
||||
|
||||
if self.send_processing_hint:
|
||||
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
|
||||
try:
|
||||
self.reply_text(hint, msg)
|
||||
except Exception:
|
||||
self.logger.exception("failed to send processing hint")
|
||||
|
||||
session_id = self._session_id(msg)
|
||||
if self._is_market_prompt(prompt):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
answer = self._run_market_brief(subject)
|
||||
else:
|
||||
answer = self._run_openclaw(session_id, prompt)
|
||||
|
||||
answer = self._sanitize_answer(answer)
|
||||
answer = self._mask_sensitive_amounts(prompt, answer)
|
||||
if len(answer) > self.reply_max_chars:
|
||||
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
|
||||
|
||||
try:
|
||||
self._reply_message(msg, answer, prefer_card=True)
|
||||
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
|
||||
except Exception:
|
||||
self.logger.exception("failed to send final reply: session=%s", session_id)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
|
||||
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
|
||||
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
|
||||
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
|
||||
|
||||
credential = Credential(client_id, client_secret)
|
||||
client = DingTalkStreamClient(credential, logger=logger)
|
||||
bot = OpenClawDingTalkBot(logger)
|
||||
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
|
||||
if bool_env("REGISTER_DELEGATE_TOPIC", False):
|
||||
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
|
||||
logger.info("delegate topic enabled")
|
||||
else:
|
||||
logger.info("delegate topic disabled (default)")
|
||||
return client
|
||||
|
||||
|
||||
def self_check(logger: logging.Logger) -> int:
|
||||
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
|
||||
missing = [k for k in required if not os.getenv(k)]
|
||||
if missing:
|
||||
logger.error("missing env: %s", ", ".join(missing))
|
||||
return 2
|
||||
|
||||
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
|
||||
return 3
|
||||
|
||||
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
if not os.path.exists(market_brief_bin):
|
||||
logger.error("market brief bin not found: %s", market_brief_bin)
|
||||
return 4
|
||||
|
||||
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
|
||||
parser.add_argument("--check", action="store_true", help="run checks and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("openclaw-dingtalk-bridge")
|
||||
|
||||
if args.check:
|
||||
return self_check(logger)
|
||||
|
||||
client = build_client(logger)
|
||||
logger.info("bridge started, listening DingTalk stream")
|
||||
client.start_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
26
fix_agents_feishu_section.py
Normal file
26
fix_agents_feishu_section.py
Normal file
@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
p = Path('/root/.openclaw/workspace/AGENTS.md')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
start = text.find('## 5) Feishu Delivery Rules\n')
|
||||
ws = text.find('## 5) Workspace Paths\n')
|
||||
if start == -1 or ws == -1 or ws < start:
|
||||
raise SystemExit('section markers not found')
|
||||
new_section = '''## 5) Feishu Delivery Rules
|
||||
|
||||
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
|
||||
|
||||
Required behavior:
|
||||
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
|
||||
- In final reply, always include:
|
||||
1) push status (success/failed)
|
||||
2) target sheet link (the user-provided wiki/base URL)
|
||||
3) written record_id (or updated count)
|
||||
- If write partially fails, return succeeded records and failed reason list separately.
|
||||
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
|
||||
|
||||
'''
|
||||
text = text[:start] + new_section + text[ws:]
|
||||
text = text.replace('## 5) Workspace Paths\n', '## 6) Workspace Paths\n', 1)
|
||||
p.write_text(text, encoding='utf-8')
|
||||
print('fixed', p)
|
||||
51
fix_websites_stock_keywords.py
Normal file
51
fix_websites_stock_keywords.py
Normal file
@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
p=Path('/root/.openclaw/workspace/websites.yaml')
|
||||
lines=p.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
# Ensure hard route keywords contain inventory terms
|
||||
hard_start=None
|
||||
hard_end=None
|
||||
for i,l in enumerate(lines):
|
||||
if l.strip()=='- when_keywords:' and i>0 and 'hard_route_rules:' in lines[i-1]:
|
||||
# first rule, skip
|
||||
pass
|
||||
for i,l in enumerate(lines):
|
||||
if l.strip()=='- when_keywords:' and i+1 < len(lines) and lines[i+1].strip()=='- 销售数据':
|
||||
hard_start=i
|
||||
# find force_site_id line
|
||||
for j in range(i+1,len(lines)):
|
||||
if 'force_site_id: company-sales-db-analysis' in lines[j]:
|
||||
hard_end=j
|
||||
break
|
||||
break
|
||||
if hard_start is not None and hard_end is not None:
|
||||
existing=set(l.strip().lstrip('-').strip() for l in lines[hard_start:hard_end] if l.strip().startswith('- '))
|
||||
need=['库存','缺货','补货','安全库存']
|
||||
insert_pos=hard_end
|
||||
for kw in need:
|
||||
if kw not in existing:
|
||||
lines.insert(insert_pos, ' - '+kw)
|
||||
insert_pos+=1
|
||||
|
||||
# Ensure site keywords contain inventory terms
|
||||
site_kw_start=None
|
||||
site_kw_end=None
|
||||
for i,l in enumerate(lines):
|
||||
if l.strip()=='keywords:' and i>0 and lines[i-1].strip()=='- 物流与安装协同分析':
|
||||
site_kw_start=i
|
||||
for j in range(i+1,len(lines)):
|
||||
if lines[j].strip()=='login_required: false':
|
||||
site_kw_end=j
|
||||
break
|
||||
break
|
||||
if site_kw_start is not None and site_kw_end is not None:
|
||||
existing=set(l.strip().lstrip('-').strip() for l in lines[site_kw_start:site_kw_end] if l.strip().startswith('- '))
|
||||
need=['库存','缺货','补货','安全库存']
|
||||
insert_pos=site_kw_end
|
||||
for kw in need:
|
||||
if kw not in existing:
|
||||
lines.insert(insert_pos, ' - '+kw)
|
||||
insert_pos+=1
|
||||
|
||||
p.write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
||||
print('updated',p)
|
||||
11
openclaw-caddy-local-root.crt
Normal file
11
openclaw-caddy-local-root.crt
Normal file
@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBpDCCAUmgAwIBAgIQANEkK1m3v+0fBES3/FsNCzAKBggqhkjOPQQDAjAwMS4w
|
||||
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X
|
||||
DTI2MDIwODAyMTExMloXDTM1MTIxODAyMTExMlowMDEuMCwGA1UEAxMlQ2FkZHkg
|
||||
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
|
||||
SM49AwEHA0IABA6AXOtkhlMavSBG9ZKAhVl6cqcjYfdSGZRNZweHSTGaWFUyp+2z
|
||||
kcYEXLNFPL7M8TpDfSBOPKM1P4vIdfBjJhKjRTBDMA4GA1UdDwEB/wQEAwIBBjAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSaSmQ0hDEj0WAnSVCtZ80wsWii
|
||||
WDAKBggqhkjOPQQDAgNJADBGAiEAyv1iBqcTKsMWTPYOjbGkeyYXCLIlIbEWznSx
|
||||
VRKhL6gCIQDVW8oVdtANrvqtwpJNnVvOeXO43JeOT9+Qx56z2UZK6A==
|
||||
-----END CERTIFICATE-----
|
||||
893
openclaw-log-observer
Executable file
893
openclaw-log-observer
Executable file
@ -0,0 +1,893 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
DEFAULT_APP_TOKEN = "GC2Mb4TXwad8ZusStBecLNg8nCe"
|
||||
DEFAULT_TABLE_LINK = "https://acnuoqgha8fk.feishu.cn/base/GC2Mb4TXwad8ZusStBecLNg8nCe"
|
||||
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
|
||||
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
|
||||
REPORT_DIR = Path("/var/log/openclaw/log-observer")
|
||||
|
||||
REQUIRED_FIELDS = [
|
||||
"记录类型",
|
||||
"批次ID",
|
||||
"巡检时间",
|
||||
"日志范围(分钟)",
|
||||
"日志总行数",
|
||||
"OpenClaw调用次数",
|
||||
"成功回复次数",
|
||||
"未完成会话数",
|
||||
"错误总数",
|
||||
"网络错误数",
|
||||
"解析错误数",
|
||||
"重复回调数",
|
||||
"问题类型",
|
||||
"会话ID",
|
||||
"问题摘要",
|
||||
"建议动作",
|
||||
"自动回放状态",
|
||||
"自动回放会话",
|
||||
"自动回放摘要",
|
||||
"状态",
|
||||
]
|
||||
|
||||
ISSUE_PATTERNS = {
|
||||
"openclaw_call_failed": re.compile(r"OpenClaw 调用失败|openclaw call timeout|OpenClaw 调用超时", re.I),
|
||||
"parse_failed": re.compile(r"结果解析失败|parse openclaw output failed", re.I),
|
||||
"network_error": re.compile(r"network exception|keepalive ping timeout|timed out during opening handshake", re.I),
|
||||
"reply_failed": re.compile(r"reply failed: session=|failed to send final reply", re.I),
|
||||
"traceback": re.compile(r"Traceback \(most recent call last\)", re.I),
|
||||
}
|
||||
|
||||
DIALOG_PATTERNS = {
|
||||
"dialog_tool_noise": re.compile(
|
||||
r"NO TOOLBeenCalled|without a preceding text-to-speech|tts\s*Completed|结果解析失败|原始输出:",
|
||||
re.I,
|
||||
),
|
||||
"dialog_blocked_response": re.compile(
|
||||
r"权限不足|未登录|无法直接从页面抓取|需要登录后的操作|工具版本不支持点击交互|device identity required|网关令牌缺失|需要配对",
|
||||
re.I,
|
||||
),
|
||||
"dialog_meta_leak": re.compile(r"\[plugins\]|Registered\s+\w+\s+tool|runId\"\s*:\s*\"", re.I),
|
||||
}
|
||||
|
||||
CALL_RE = re.compile(r"openclaw call: session=([^\s]+)")
|
||||
REPLY_RE = re.compile(r"reply sent: session=([^\s]+)")
|
||||
DUP_RE = re.compile(r"duplicate callback ignored", re.I)
|
||||
TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+")
|
||||
SESSION_RE = re.compile(r"session=([^\s,]+)")
|
||||
|
||||
|
||||
def _run(cmd: List[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict:
|
||||
data = None
|
||||
req_headers = dict(headers)
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"request failed {method} {url}: {e}") from e
|
||||
|
||||
|
||||
def _load_feishu_app() -> Tuple[str, str]:
|
||||
app_id = os.getenv("FEISHU_APP_ID", "").strip()
|
||||
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
|
||||
if app_id and app_secret:
|
||||
return app_id, app_secret
|
||||
|
||||
cfg_path = Path(OPENCLAW_CONFIG)
|
||||
if not cfg_path.exists():
|
||||
raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)")
|
||||
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
ch = (cfg.get("channels") or {}).get("feishu") or {}
|
||||
app_id = (ch.get("appId") or "").strip()
|
||||
app_secret = (ch.get("appSecret") or "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise RuntimeError("feishu appId/appSecret is empty in openclaw.json")
|
||||
return app_id, app_secret
|
||||
|
||||
|
||||
def _tenant_token(app_id: str, app_secret: str) -> str:
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
res = _http_json("POST", url, headers={}, payload={"app_id": app_id, "app_secret": app_secret})
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"feishu auth failed: {res}")
|
||||
token = res.get("tenant_access_token")
|
||||
if not token:
|
||||
raise RuntimeError("feishu auth succeeded but tenant_access_token missing")
|
||||
return token
|
||||
|
||||
|
||||
def _create_table(token: str, app_token: str, table_name: str) -> str:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||
res = _http_json(
|
||||
"POST",
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
payload={"table": {"name": table_name[:100]}},
|
||||
)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"create table failed ({table_name}): {res}")
|
||||
|
||||
data = res.get("data") or {}
|
||||
table_id = data.get("table_id") or (data.get("table") or {}).get("table_id") or ""
|
||||
if not table_id:
|
||||
raise RuntimeError(f"create table succeeded but table_id missing: {res}")
|
||||
return table_id
|
||||
|
||||
|
||||
def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields?page_size=500"
|
||||
res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"})
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"list fields failed: {res}")
|
||||
out: Dict[str, str] = {}
|
||||
for it in ((res.get("data") or {}).get("items") or []):
|
||||
name = it.get("field_name")
|
||||
fid = it.get("field_id")
|
||||
if name and fid:
|
||||
out[name] = fid
|
||||
return out
|
||||
|
||||
|
||||
def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
res = _http_json(
|
||||
"POST",
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
payload={"field_name": field_name, "type": 1},
|
||||
)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"create field failed ({field_name}): {res}")
|
||||
|
||||
|
||||
def _ensure_fields(token: str, app_token: str, table_id: str) -> None:
|
||||
fields = _list_fields(token, app_token, table_id)
|
||||
for name in REQUIRED_FIELDS:
|
||||
if name not in fields:
|
||||
_create_field(token, app_token, table_id, name)
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> None:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
res = _http_json(
|
||||
"POST",
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
payload={"fields": fields},
|
||||
)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"create record failed: {res}")
|
||||
|
||||
|
||||
def _build_table_link(base_link: str, app_token: str, table_id: str) -> str:
|
||||
if not base_link:
|
||||
return f"https://acnuoqgha8fk.feishu.cn/base/{app_token}?table={table_id}"
|
||||
parsed = urllib.parse.urlsplit(base_link)
|
||||
query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
query_pairs = [(k, v) for (k, v) in query_pairs if k.lower() not in {"table", "view"}]
|
||||
query_pairs.append(("table", table_id))
|
||||
query = urllib.parse.urlencode(query_pairs)
|
||||
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment))
|
||||
|
||||
|
||||
def _extract_text_from_openclaw(stdout: str) -> str:
|
||||
raw = (stdout or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
decoder = json.JSONDecoder()
|
||||
data = None
|
||||
idx = raw.find("{")
|
||||
while idx != -1:
|
||||
try:
|
||||
data, _ = decoder.raw_decode(raw[idx:])
|
||||
break
|
||||
except Exception:
|
||||
idx = raw.find("{", idx + 1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
payloads = ((data.get("result") or {}).get("payloads") or [])
|
||||
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
|
||||
return "\n".join(t for t in text_items if t).strip()
|
||||
|
||||
|
||||
def _collect_logs(service: str, since_minutes: int) -> List[str]:
|
||||
cmd = [
|
||||
"journalctl",
|
||||
"-u",
|
||||
service,
|
||||
"--since",
|
||||
f"{since_minutes} min ago",
|
||||
"--no-pager",
|
||||
"-o",
|
||||
"short-iso",
|
||||
]
|
||||
proc = _run(cmd)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "journalctl failed").strip()
|
||||
raise RuntimeError(f"collect logs failed: {err}")
|
||||
return [line.rstrip("\n") for line in (proc.stdout or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _extract_session(line: str) -> str:
|
||||
for regex in (CALL_RE, REPLY_RE, SESSION_RE):
|
||||
m = regex.search(line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def _analyze_logs(lines: List[str]) -> Dict:
|
||||
call_count = 0
|
||||
reply_count = 0
|
||||
duplicate_count = 0
|
||||
issue_counts = {k: 0 for k in ISSUE_PATTERNS}
|
||||
calls_by_session: Dict[str, int] = {}
|
||||
replies_by_session: Dict[str, int] = {}
|
||||
issues: List[Dict] = []
|
||||
|
||||
for line in lines:
|
||||
m = CALL_RE.search(line)
|
||||
if m:
|
||||
call_count += 1
|
||||
session = m.group(1)
|
||||
calls_by_session[session] = calls_by_session.get(session, 0) + 1
|
||||
|
||||
m = REPLY_RE.search(line)
|
||||
if m:
|
||||
reply_count += 1
|
||||
session = m.group(1)
|
||||
replies_by_session[session] = replies_by_session.get(session, 0) + 1
|
||||
|
||||
if DUP_RE.search(line):
|
||||
duplicate_count += 1
|
||||
|
||||
for issue_type, patt in ISSUE_PATTERNS.items():
|
||||
if patt.search(line):
|
||||
issue_counts[issue_type] += 1
|
||||
ts_match = TS_RE.match(line)
|
||||
issues.append(
|
||||
{
|
||||
"type": issue_type,
|
||||
"timestamp": ts_match.group(1) if ts_match else "",
|
||||
"line": line[-600:],
|
||||
"session": _extract_session(line),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
unresolved_sessions = []
|
||||
for session, cnt in calls_by_session.items():
|
||||
rc = replies_by_session.get(session, 0)
|
||||
if cnt > rc:
|
||||
unresolved_sessions.append({"session": session, "pending_calls": cnt - rc})
|
||||
|
||||
unresolved_sessions = sorted(unresolved_sessions, key=lambda x: (-x["pending_calls"], x["session"]))
|
||||
|
||||
return {
|
||||
"line_count": len(lines),
|
||||
"call_count": call_count,
|
||||
"reply_count": reply_count,
|
||||
"duplicate_count": duplicate_count,
|
||||
"issue_counts": issue_counts,
|
||||
"issues": issues,
|
||||
"unresolved_sessions": unresolved_sessions,
|
||||
}
|
||||
|
||||
|
||||
def _collect_session_ids(lines: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
out = []
|
||||
for line in lines:
|
||||
sid = _extract_session(line)
|
||||
if sid and sid not in seen:
|
||||
seen.add(sid)
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_iso_utc(value: str) -> Optional[dt.datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
parsed = dt.datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
||||
return parsed.astimezone(dt.timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_content_text(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") != "text":
|
||||
continue
|
||||
text = item.get("text")
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
return "\n".join(parts).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
return re.sub(r"[\s,,。.!!??::~~`'\"\-_/\\]+", "", text or "").lower()
|
||||
|
||||
|
||||
def _dialog_issue(
|
||||
issue_type: str,
|
||||
session_id: str,
|
||||
timestamp: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
) -> Dict:
|
||||
user_short = (user_text or "").replace("\n", " ")[:120]
|
||||
assistant_short = (assistant_text or "").replace("\n", " ")[:320]
|
||||
line = f"user={user_short} | assistant={assistant_short}"
|
||||
return {
|
||||
"type": issue_type,
|
||||
"timestamp": timestamp,
|
||||
"line": line,
|
||||
"session": session_id,
|
||||
}
|
||||
|
||||
|
||||
def _analyze_dialog_anomalies(lines: List[str], since_minutes: int) -> Dict:
|
||||
session_ids = _collect_session_ids(lines)
|
||||
cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=since_minutes)
|
||||
|
||||
issue_counts = {k: 0 for k in DIALOG_PATTERNS}
|
||||
issue_counts["dialog_repeat_reply"] = 0
|
||||
issue_counts["dialog_large_code_dump"] = 0
|
||||
issues: List[Dict] = []
|
||||
inspected = 0
|
||||
|
||||
for session_id in session_ids:
|
||||
session_file = SESSIONS_DIR / f"{session_id}.jsonl"
|
||||
if not session_file.exists() or not session_file.is_file():
|
||||
continue
|
||||
|
||||
inspected += 1
|
||||
last_user_text = ""
|
||||
last_assistant_norm = ""
|
||||
|
||||
try:
|
||||
with session_file.open("r", encoding="utf-8") as fh:
|
||||
for raw in fh:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if obj.get("type") != "message":
|
||||
continue
|
||||
|
||||
msg = obj.get("message") or {}
|
||||
role = (msg.get("role") or "").lower()
|
||||
content_text = _extract_content_text(msg.get("content"))
|
||||
if not content_text:
|
||||
continue
|
||||
|
||||
ts_raw = obj.get("timestamp") or ""
|
||||
ts_dt = _parse_iso_utc(ts_raw)
|
||||
if ts_dt and ts_dt < cutoff:
|
||||
continue
|
||||
ts_text = ts_raw or ""
|
||||
|
||||
if role == "user":
|
||||
last_user_text = content_text
|
||||
continue
|
||||
if role != "assistant":
|
||||
continue
|
||||
|
||||
normalized = _normalize_text(content_text)
|
||||
for issue_type, patt in DIALOG_PATTERNS.items():
|
||||
if patt.search(content_text):
|
||||
issue_counts[issue_type] += 1
|
||||
issues.append(_dialog_issue(issue_type, session_id, ts_text, last_user_text, content_text))
|
||||
break
|
||||
|
||||
if normalized and last_assistant_norm and normalized == last_assistant_norm and len(normalized) >= 24:
|
||||
issue_counts["dialog_repeat_reply"] += 1
|
||||
issues.append(_dialog_issue("dialog_repeat_reply", session_id, ts_text, last_user_text, content_text))
|
||||
last_assistant_norm = normalized
|
||||
|
||||
if "```" in content_text:
|
||||
code_line_count = sum(1 for ln in content_text.splitlines() if ln.strip())
|
||||
if code_line_count >= 24:
|
||||
issue_counts["dialog_large_code_dump"] += 1
|
||||
issues.append(_dialog_issue("dialog_large_code_dump", session_id, ts_text, last_user_text, content_text))
|
||||
except Exception:
|
||||
issues.append(
|
||||
{
|
||||
"type": "dialog_session_parse_failed",
|
||||
"timestamp": dt.datetime.now().isoformat(),
|
||||
"line": f"session file parse failed: {session_file}",
|
||||
"session": session_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"dialog_issue_counts": issue_counts,
|
||||
"dialog_issues": issues,
|
||||
"dialog_inspected_sessions": inspected,
|
||||
}
|
||||
|
||||
|
||||
def _last_user_prompt(session_id: str) -> str:
|
||||
p = SESSIONS_DIR / f"{session_id}.jsonl"
|
||||
if not p.exists() or p.stat().st_size == 0:
|
||||
return ""
|
||||
|
||||
prompt = ""
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as fh:
|
||||
for raw in fh:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if obj.get("type") != "message":
|
||||
continue
|
||||
message = obj.get("message") or {}
|
||||
role = (message.get("role") or "").lower()
|
||||
if role != "user":
|
||||
continue
|
||||
content = _extract_content_text(message.get("content"))
|
||||
if content:
|
||||
prompt = content
|
||||
except Exception:
|
||||
return ""
|
||||
return prompt[:1200]
|
||||
|
||||
|
||||
def _is_safe_readonly_prompt(prompt: str) -> bool:
|
||||
if not prompt:
|
||||
return False
|
||||
lowered = prompt.lower()
|
||||
dangerous = ["删除", "drop ", "truncate", "update ", "insert ", "修改", "推送", "发送", "写入", "支付", "转账"]
|
||||
if any(k in lowered for k in dangerous):
|
||||
return False
|
||||
read_only = ["查询", "分析", "检查", "统计", "汇总", "搜索", "报表", "售后", "库存", "sku", "店铺"]
|
||||
return any(k in lowered for k in read_only)
|
||||
|
||||
|
||||
def _openclaw_replay(prompt: str, timeout: int = 240) -> Tuple[bool, str, str]:
|
||||
replay_session = f"observer-replay-{int(time.time())}-{hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:6]}"
|
||||
cmd = [
|
||||
"/usr/bin/openclaw",
|
||||
"agent",
|
||||
"--session-id",
|
||||
replay_session,
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
]
|
||||
proc = _run(cmd, timeout=timeout + 20)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw replay failed").strip()
|
||||
return False, replay_session, err[:300]
|
||||
|
||||
text = _extract_text_from_openclaw(proc.stdout or "")
|
||||
if not text:
|
||||
text = (proc.stdout or "")[:300]
|
||||
ok = "调用失败" not in text and "超时" not in text and "解析失败" not in text
|
||||
summary = text.replace("\n", " ")[:260]
|
||||
return ok, replay_session, summary
|
||||
|
||||
|
||||
def _total_issue_count(analysis: Dict) -> int:
|
||||
base = sum((analysis.get("issue_counts") or {}).values())
|
||||
dialog = sum((analysis.get("dialog_issue_counts") or {}).values())
|
||||
return base + dialog
|
||||
|
||||
|
||||
def _openclaw_observer_comment(analysis: Dict, top_lines: List[str], timeout: int = 180) -> str:
|
||||
payload = {
|
||||
"summary": {
|
||||
"line_count": analysis["line_count"],
|
||||
"call_count": analysis["call_count"],
|
||||
"reply_count": analysis["reply_count"],
|
||||
"duplicate_count": analysis["duplicate_count"],
|
||||
"issue_counts": analysis["issue_counts"],
|
||||
"dialog_issue_counts": analysis.get("dialog_issue_counts", {}),
|
||||
"dialog_inspected_sessions": analysis.get("dialog_inspected_sessions", 0),
|
||||
"unresolved_sessions": analysis["unresolved_sessions"][:10],
|
||||
},
|
||||
"samples": top_lines[:20],
|
||||
}
|
||||
|
||||
prompt = (
|
||||
"你是日志巡检分身。根据以下 JSON 生成简短巡检结论,输出三段:"
|
||||
"\n1) 主要问题(最多3条)"
|
||||
"\n2) 对主机器人的修复建议(最多5条,必须可执行)"
|
||||
"\n3) 优先级最高的一条修复动作"
|
||||
"\n请重点覆盖对话返回异常(如无关输出、工具噪音、重复回复)。"
|
||||
"\n请用中文、短句、不要废话。\n\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"/usr/bin/openclaw",
|
||||
"agent",
|
||||
"--session-id",
|
||||
"log-observer-main",
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
]
|
||||
proc = _run(cmd, timeout=timeout + 20)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw observer failed").strip()
|
||||
return f"分身分析失败: {err[:300]}"
|
||||
text = _extract_text_from_openclaw(proc.stdout or "")
|
||||
return text or "分身分析完成,但未返回文本。"
|
||||
|
||||
|
||||
def _recommendation(issue_type: str) -> str:
|
||||
mapping = {
|
||||
"network_error": "检查网络抖动与 websocket keepalive,建议提高重连退避并增加心跳监控。",
|
||||
"parse_failed": "统一 OpenClaw 输出解析入口,先剥离插件日志再解码 JSON。",
|
||||
"openclaw_call_failed": "检查模型/API可用性与超时阈值,必要时降级到本地简答模式。",
|
||||
"reply_failed": "检查 ActionCard 权限,失败时强制文本兜底避免无回复。",
|
||||
"traceback": "排查异常栈并加防护,避免同类异常重复触发。",
|
||||
"dialog_tool_noise": "在回复前增加输出清洗,拦截 tool 元信息/tts 噪音文本。",
|
||||
"dialog_blocked_response": "对登录/权限失败加自动重试和明确下一步,不直接抛给用户。",
|
||||
"dialog_meta_leak": "过滤插件注册/运行元数据,用户侧只保留结论。",
|
||||
"dialog_repeat_reply": "对同会话相同答复加内容去重,避免重复发送。",
|
||||
"dialog_large_code_dump": "非代码任务启用摘要模式,限制长代码块直接外发。",
|
||||
"dialog_session_parse_failed": "检查 session 文件格式和异常记录,保证可解析。",
|
||||
}
|
||||
return mapping.get(issue_type, "检查对应日志并补充防护与重试策略。")
|
||||
|
||||
|
||||
def _write_report(batch_id: str, since_minutes: int, analysis: Dict, observer_text: str, replay_rows: List[Dict], lines: List[str]) -> Path:
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
report_path = REPORT_DIR / f"{batch_id}.md"
|
||||
|
||||
issues_top = analysis["issues"][:16]
|
||||
unresolved_top = analysis["unresolved_sessions"][:12]
|
||||
dialog_top = (analysis.get("dialog_issues") or [])[:16]
|
||||
|
||||
out = []
|
||||
out.append(f"# 日志巡检报告 {batch_id}")
|
||||
out.append("")
|
||||
out.append(f"- 巡检时间: {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
out.append(f"- 日志范围: 最近 {since_minutes} 分钟")
|
||||
out.append(f"- 日志总行数: {analysis['line_count']}")
|
||||
out.append(f"- OpenClaw调用/成功回复: {analysis['call_count']} / {analysis['reply_count']}")
|
||||
out.append(f"- 未完成会话: {len(analysis['unresolved_sessions'])}")
|
||||
out.append(f"- 错误总数: {_total_issue_count(analysis)}")
|
||||
out.append(f"- 对话巡检会话数: {analysis.get('dialog_inspected_sessions', 0)}")
|
||||
out.append(f"- 对话内容异常数: {sum((analysis.get('dialog_issue_counts') or {}).values())}")
|
||||
out.append("")
|
||||
out.append("## 分身结论")
|
||||
out.append("")
|
||||
out.append(observer_text.strip() or "无")
|
||||
out.append("")
|
||||
|
||||
out.append("## 问题明细")
|
||||
out.append("")
|
||||
if not issues_top:
|
||||
out.append("- 无错误问题")
|
||||
else:
|
||||
for idx, row in enumerate(issues_top, start=1):
|
||||
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
|
||||
out.append(f" - 时间: {row.get('timestamp') or '-'}")
|
||||
out.append(f" - 日志: {row.get('line')}")
|
||||
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
|
||||
|
||||
out.append("")
|
||||
out.append("## 对话内容异常")
|
||||
out.append("")
|
||||
if not dialog_top:
|
||||
out.append("- 无")
|
||||
else:
|
||||
for idx, row in enumerate(dialog_top, start=1):
|
||||
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
|
||||
out.append(f" - 时间: {row.get('timestamp') or '-'}")
|
||||
out.append(f" - 内容: {row.get('line')}")
|
||||
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
|
||||
|
||||
out.append("")
|
||||
out.append("## 未完成会话")
|
||||
out.append("")
|
||||
if not unresolved_top:
|
||||
out.append("- 无")
|
||||
else:
|
||||
for row in unresolved_top:
|
||||
out.append(f"- {row['session']}: pending_calls={row['pending_calls']}")
|
||||
|
||||
out.append("")
|
||||
out.append("## 自动回放")
|
||||
out.append("")
|
||||
if not replay_rows:
|
||||
out.append("- 未执行")
|
||||
else:
|
||||
for row in replay_rows:
|
||||
out.append(f"- {row['session']} -> {row['status']} ({row['replay_session']}) {row['summary']}")
|
||||
|
||||
out.append("")
|
||||
out.append("## 日志样本")
|
||||
out.append("")
|
||||
for line in lines[-40:]:
|
||||
out.append(f"- {line[-800:]}")
|
||||
|
||||
report_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
latest = REPORT_DIR / "latest.md"
|
||||
latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def _push_to_feishu(
|
||||
batch_id: str,
|
||||
since_minutes: int,
|
||||
analysis: Dict,
|
||||
observer_text: str,
|
||||
replay_rows: List[Dict],
|
||||
app_token: str,
|
||||
base_link: str,
|
||||
shared_table: bool,
|
||||
) -> Tuple[str, str]:
|
||||
app_id, app_secret = _load_feishu_app()
|
||||
token = _tenant_token(app_id, app_secret)
|
||||
|
||||
table_name = f"日志巡检-{dt.datetime.now().strftime('%m%d-%H%M')}"
|
||||
table_id = ""
|
||||
if shared_table:
|
||||
parsed = urllib.parse.urlsplit(base_link or "")
|
||||
query = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
|
||||
table_id = query.get("table", "").strip()
|
||||
if not table_id:
|
||||
raise RuntimeError("shared-table 模式下未提供 table_id")
|
||||
else:
|
||||
table_id = _create_table(token, app_token, table_name)
|
||||
|
||||
_ensure_fields(token, app_token, table_id)
|
||||
now_text = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
_create_record(
|
||||
token,
|
||||
app_token,
|
||||
table_id,
|
||||
{
|
||||
"记录类型": "summary",
|
||||
"批次ID": batch_id,
|
||||
"巡检时间": now_text,
|
||||
"日志范围(分钟)": str(since_minutes),
|
||||
"日志总行数": str(analysis["line_count"]),
|
||||
"OpenClaw调用次数": str(analysis["call_count"]),
|
||||
"成功回复次数": str(analysis["reply_count"]),
|
||||
"未完成会话数": str(len(analysis["unresolved_sessions"])),
|
||||
"错误总数": str(_total_issue_count(analysis)),
|
||||
"网络错误数": str(analysis["issue_counts"].get("network_error", 0)),
|
||||
"解析错误数": str(analysis["issue_counts"].get("parse_failed", 0)),
|
||||
"重复回调数": str(analysis["duplicate_count"]),
|
||||
"问题类型": "巡检汇总",
|
||||
"会话ID": "-",
|
||||
"问题摘要": (
|
||||
observer_text[:5000]
|
||||
+ "\n\n"
|
||||
+ f"对话巡检会话数={analysis.get('dialog_inspected_sessions', 0)}, 对话内容异常数={sum((analysis.get('dialog_issue_counts') or {}).values())}"
|
||||
)[:8000],
|
||||
"建议动作": "按优先级逐条修复,修复后观察下一周期是否下降。",
|
||||
"自动回放状态": "已执行" if replay_rows else "未执行",
|
||||
"自动回放会话": str(len(replay_rows)),
|
||||
"自动回放摘要": "; ".join([f"{r['session']}:{r['status']}" for r in replay_rows])[:8000],
|
||||
"状态": "ok",
|
||||
},
|
||||
)
|
||||
|
||||
for row in analysis["issues"][:40]:
|
||||
_create_record(
|
||||
token,
|
||||
app_token,
|
||||
table_id,
|
||||
{
|
||||
"记录类型": "issue",
|
||||
"批次ID": batch_id,
|
||||
"巡检时间": row.get("timestamp") or now_text,
|
||||
"日志范围(分钟)": str(since_minutes),
|
||||
"日志总行数": "",
|
||||
"OpenClaw调用次数": "",
|
||||
"成功回复次数": "",
|
||||
"未完成会话数": "",
|
||||
"错误总数": "",
|
||||
"网络错误数": "",
|
||||
"解析错误数": "",
|
||||
"重复回调数": "",
|
||||
"问题类型": row.get("type") or "",
|
||||
"会话ID": row.get("session") or "",
|
||||
"问题摘要": row.get("line", "")[:8000],
|
||||
"建议动作": _recommendation(row.get("type") or ""),
|
||||
"自动回放状态": "",
|
||||
"自动回放会话": "",
|
||||
"自动回放摘要": "",
|
||||
"状态": "pending",
|
||||
},
|
||||
)
|
||||
|
||||
for row in replay_rows[:20]:
|
||||
_create_record(
|
||||
token,
|
||||
app_token,
|
||||
table_id,
|
||||
{
|
||||
"记录类型": "replay",
|
||||
"批次ID": batch_id,
|
||||
"巡检时间": now_text,
|
||||
"日志范围(分钟)": str(since_minutes),
|
||||
"日志总行数": "",
|
||||
"OpenClaw调用次数": "",
|
||||
"成功回复次数": "",
|
||||
"未完成会话数": "",
|
||||
"错误总数": "",
|
||||
"网络错误数": "",
|
||||
"解析错误数": "",
|
||||
"重复回调数": "",
|
||||
"问题类型": "auto-replay",
|
||||
"会话ID": row.get("session") or "",
|
||||
"问题摘要": row.get("prompt", "")[:8000],
|
||||
"建议动作": "若回放成功,检查主机器人同类问题是否已恢复。",
|
||||
"自动回放状态": row.get("status", ""),
|
||||
"自动回放会话": row.get("replay_session", ""),
|
||||
"自动回放摘要": row.get("summary", "")[:8000],
|
||||
"状态": "ok" if row.get("status") == "success" else "failed",
|
||||
},
|
||||
)
|
||||
|
||||
table_link = _build_table_link(base_link, app_token, table_id)
|
||||
return table_id, table_link
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw log observer")
|
||||
parser.add_argument("--service", default="openclaw-dingtalk-bridge.service")
|
||||
parser.add_argument("--since-minutes", type=int, default=60)
|
||||
parser.add_argument("--push-feishu", action="store_true")
|
||||
parser.add_argument("--shared-table", action="store_true")
|
||||
parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN)
|
||||
parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK)
|
||||
parser.add_argument("--replay-failures", action="store_true")
|
||||
parser.add_argument("--replay-limit", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
|
||||
since_minutes = max(5, min(args.since_minutes, 24 * 60))
|
||||
batch_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
try:
|
||||
lines = _collect_logs(args.service, since_minutes)
|
||||
analysis = _analyze_logs(lines)
|
||||
dialog_result = _analyze_dialog_anomalies(lines, since_minutes)
|
||||
analysis.update(dialog_result)
|
||||
analysis["issues"].extend(dialog_result.get("dialog_issues", []))
|
||||
except Exception as e:
|
||||
print("STATUS=error")
|
||||
print(f"ERROR={e}")
|
||||
return 2
|
||||
|
||||
issue_lines = [row["line"] for row in analysis["issues"][:20]]
|
||||
observer_text = _openclaw_observer_comment(analysis, issue_lines)
|
||||
|
||||
replay_rows: List[Dict] = []
|
||||
if args.replay_failures:
|
||||
for item in analysis["unresolved_sessions"][: max(0, args.replay_limit)]:
|
||||
session_id = item["session"]
|
||||
prompt = _last_user_prompt(session_id)
|
||||
if not _is_safe_readonly_prompt(prompt):
|
||||
replay_rows.append(
|
||||
{
|
||||
"session": session_id,
|
||||
"prompt": prompt[:200],
|
||||
"status": "skipped",
|
||||
"replay_session": "",
|
||||
"summary": "prompt为空或非只读任务,跳过",
|
||||
}
|
||||
)
|
||||
continue
|
||||
ok, replay_session, summary = _openclaw_replay(prompt)
|
||||
replay_rows.append(
|
||||
{
|
||||
"session": session_id,
|
||||
"prompt": prompt[:500],
|
||||
"status": "success" if ok else "failed",
|
||||
"replay_session": replay_session,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
report_path = _write_report(batch_id, since_minutes, analysis, observer_text, replay_rows, lines)
|
||||
|
||||
table_id = ""
|
||||
table_link = ""
|
||||
if args.push_feishu:
|
||||
try:
|
||||
table_id, table_link = _push_to_feishu(
|
||||
batch_id=batch_id,
|
||||
since_minutes=since_minutes,
|
||||
analysis=analysis,
|
||||
observer_text=observer_text,
|
||||
replay_rows=replay_rows,
|
||||
app_token=args.app_token,
|
||||
base_link=args.table_link,
|
||||
shared_table=args.shared_table,
|
||||
)
|
||||
except Exception as e:
|
||||
print("STATUS=partial")
|
||||
print(f"ERROR=push feishu failed: {e}")
|
||||
print(f"BATCH_ID={batch_id}")
|
||||
print(f"REPORT_PATH={report_path}")
|
||||
print(f"LINE_COUNT={analysis['line_count']}")
|
||||
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
|
||||
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
|
||||
return 1
|
||||
|
||||
print("STATUS=ok")
|
||||
print(f"BATCH_ID={batch_id}")
|
||||
print(f"REPORT_PATH={report_path}")
|
||||
print(f"LINE_COUNT={analysis['line_count']}")
|
||||
print(f"CALL_COUNT={analysis['call_count']}")
|
||||
print(f"REPLY_COUNT={analysis['reply_count']}")
|
||||
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
|
||||
print(f"NETWORK_ERRORS={analysis['issue_counts'].get('network_error', 0)}")
|
||||
print(f"PARSE_ERRORS={analysis['issue_counts'].get('parse_failed', 0)}")
|
||||
print(f"DUPLICATE_CALLBACKS={analysis['duplicate_count']}")
|
||||
print(f"DIALOG_INSPECTED_SESSIONS={analysis.get('dialog_inspected_sessions', 0)}")
|
||||
print(f"DIALOG_ANOMALIES={sum((analysis.get('dialog_issue_counts') or {}).values())}")
|
||||
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
|
||||
print(f"REPLAY_ROWS={len(replay_rows)}")
|
||||
if table_id:
|
||||
print(f"TABLE_ID={table_id}")
|
||||
if table_link:
|
||||
print(f"TABLE_LINK={table_link}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
83
patch_bot_run_openclaw.py
Normal file
83
patch_bot_run_openclaw.py
Normal file
@ -0,0 +1,83 @@
|
||||
from pathlib import Path
|
||||
|
||||
path = Path('/opt/openclaw-dingtalk-bridge/bot.py')
|
||||
text = path.read_text(encoding='utf-8')
|
||||
start_marker = ' def _run_openclaw(self, session_id: str, prompt: str) -> str:\n'
|
||||
end_marker = '\n def _is_image_url(self, raw_url: str) -> bool:\n'
|
||||
start = text.find(start_marker)
|
||||
end = text.find(end_marker, start)
|
||||
if start == -1 or end == -1:
|
||||
raise SystemExit('failed to locate _run_openclaw boundaries')
|
||||
|
||||
replacement = ''' def _run_openclaw(self, session_id: str, prompt: str) -> str:
|
||||
cmd = [
|
||||
self.openclaw_bin,
|
||||
"agent",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(self.openclaw_timeout),
|
||||
]
|
||||
self.logger.info("openclaw call: session=%s", session_id)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.openclaw_timeout + 20,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
|
||||
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw error").strip()
|
||||
return f"OpenClaw 调用失败: {err[:500]}"
|
||||
|
||||
try:
|
||||
raw_stdout = (proc.stdout or "").strip()
|
||||
decoder = json.JSONDecoder()
|
||||
data = None
|
||||
parse_error = None
|
||||
try:
|
||||
data, _ = decoder.raw_decode(raw_stdout)
|
||||
except Exception as exc:
|
||||
parse_error = exc
|
||||
idx = raw_stdout.find("{")
|
||||
while idx != -1:
|
||||
candidate = raw_stdout[idx:]
|
||||
try:
|
||||
data, _ = decoder.raw_decode(candidate)
|
||||
break
|
||||
except Exception:
|
||||
idx = raw_stdout.find("{", idx + 1)
|
||||
if data is None:
|
||||
raise parse_error
|
||||
|
||||
payloads = data.get("result", {}).get("payloads", [])
|
||||
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
|
||||
answer = "\\n".join(t for t in text_items if t).strip()
|
||||
if not answer:
|
||||
answer = "任务已执行,但未返回文本结果。"
|
||||
|
||||
if self._is_market_prompt(prompt) and (
|
||||
"无法直接从页面抓取" in answer
|
||||
or "需要登录后的操作" in answer
|
||||
or "工具版本不支持点击交互" in answer
|
||||
):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
fallback = self._run_market_brief(subject)
|
||||
return f"已切换为市场分析直连模式({subject}):\\n\\n{fallback}"
|
||||
|
||||
return answer
|
||||
except Exception as exc:
|
||||
self.logger.exception("parse openclaw output failed")
|
||||
raw = (proc.stdout or "").strip()
|
||||
return f"结果解析失败: {exc}.\\n原始输出: {raw[:500]}"
|
||||
'''
|
||||
|
||||
new_text = text[:start] + replacement + text[end:]
|
||||
path.write_text(new_text, encoding='utf-8')
|
||||
print('patched', path)
|
||||
821
product-info
Executable file
821
product-info
Executable file
@ -0,0 +1,821 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _sql_escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def build_sql(keyword: str, days: int, limit: int) -> str:
|
||||
kw = _sql_escape(keyword.strip())
|
||||
return f"""
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @kw nvarchar(100) = N'{kw}';
|
||||
DECLARE @days int = {days};
|
||||
DECLARE @limit int = {limit};
|
||||
DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE());
|
||||
DECLARE @end_time datetime2 = GETDATE();
|
||||
DECLARE @has_exact bit = 0;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @kw)
|
||||
SET @has_exact = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL
|
||||
DROP TABLE #matched_sku;
|
||||
|
||||
SELECT TOP (300)
|
||||
s.sku_id,
|
||||
s.i_id AS style_code,
|
||||
s.other_1 AS spu,
|
||||
s.name AS sku_name,
|
||||
s.short_name,
|
||||
s.properties_value,
|
||||
s.color,
|
||||
s.item_type,
|
||||
s.category,
|
||||
s.brand,
|
||||
s.vc_name,
|
||||
s.supplier_name,
|
||||
s.supplier_sku_id,
|
||||
s.sku_code,
|
||||
s.sale_price,
|
||||
s.cost_price,
|
||||
s.purchase_price,
|
||||
s.market_price,
|
||||
s.weight,
|
||||
s.l,
|
||||
s.w,
|
||||
s.h,
|
||||
s.unit,
|
||||
s.labels,
|
||||
s.other_2 AS board_type,
|
||||
s.other_10 AS board_material,
|
||||
s.other_3 AS default_warehouse,
|
||||
s.other_5 AS sku_status,
|
||||
s.other_7 AS product_no,
|
||||
s.pic AS preview_pic_url,
|
||||
s.pic_big AS preview_pic_big_url,
|
||||
s.created,
|
||||
s.modified,
|
||||
CASE
|
||||
WHEN s.sku_id = @kw THEN 0
|
||||
WHEN s.i_id = @kw THEN 1
|
||||
WHEN s.other_1 = @kw THEN 2
|
||||
WHEN s.other_7 = @kw THEN 3
|
||||
WHEN s.sku_id LIKE @kw + '%' THEN 4
|
||||
WHEN s.name LIKE @kw + '%' THEN 5
|
||||
ELSE 9
|
||||
END AS match_rank
|
||||
INTO #matched_sku
|
||||
FROM dbo.jst_sku AS s WITH (NOLOCK)
|
||||
WHERE ISNULL(NULLIF(@kw, ''), '') <> ''
|
||||
AND (
|
||||
(@has_exact = 1 AND s.sku_id = @kw)
|
||||
OR (
|
||||
@has_exact = 0
|
||||
AND (
|
||||
s.sku_id LIKE '%' + @kw + '%'
|
||||
OR s.i_id LIKE '%' + @kw + '%'
|
||||
OR s.other_1 LIKE '%' + @kw + '%'
|
||||
OR s.name LIKE '%' + @kw + '%'
|
||||
OR s.short_name LIKE '%' + @kw + '%'
|
||||
OR s.other_7 LIKE '%' + @kw + '%'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN s.sku_id = @kw THEN 0
|
||||
WHEN s.i_id = @kw THEN 1
|
||||
WHEN s.other_1 = @kw THEN 2
|
||||
WHEN s.other_7 = @kw THEN 3
|
||||
WHEN s.sku_id LIKE @kw + '%' THEN 4
|
||||
WHEN s.name LIKE @kw + '%' THEN 5
|
||||
ELSE 9
|
||||
END,
|
||||
s.modified DESC;
|
||||
|
||||
-- 1) 产品基础信息(含SKU名称/成本/材质/预览图 + 扩展字段)
|
||||
SELECT TOP (@limit)
|
||||
sku_id,
|
||||
sku_name,
|
||||
short_name,
|
||||
style_code,
|
||||
spu,
|
||||
product_no,
|
||||
sku_code,
|
||||
properties_value,
|
||||
color,
|
||||
item_type,
|
||||
category,
|
||||
brand,
|
||||
supplier_name,
|
||||
supplier_sku_id,
|
||||
cost_price,
|
||||
purchase_price,
|
||||
sale_price,
|
||||
market_price,
|
||||
board_type,
|
||||
board_material,
|
||||
default_warehouse,
|
||||
sku_status,
|
||||
labels,
|
||||
weight,
|
||||
l,
|
||||
w,
|
||||
h,
|
||||
unit,
|
||||
preview_pic_url,
|
||||
preview_pic_big_url,
|
||||
created,
|
||||
modified
|
||||
FROM #matched_sku
|
||||
ORDER BY match_rank ASC, modified DESC;
|
||||
|
||||
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
|
||||
DROP TABLE #afs;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
ISNULL(NULLIF(a.undefined36, ''), 'unknown') AS sku_name,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
|
||||
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined37, '')) AS pre_ship_apply_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')) AS pre_ship_return_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined46, '')) AS post_ship_apply_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')) AS post_ship_return_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
|
||||
INTO #afs
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @start_time
|
||||
AND a.createdAt < @end_time
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
|
||||
AND NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku);
|
||||
|
||||
IF OBJECT_ID('tempdb..#afs_orders') IS NOT NULL
|
||||
DROP TABLE #afs_orders;
|
||||
|
||||
SELECT DISTINCT order_id
|
||||
INTO #afs_orders
|
||||
FROM #afs
|
||||
WHERE ISNULL(order_id, '') <> '';
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
|
||||
DROP TABLE #chat_latest;
|
||||
|
||||
WITH chat_rank AS (
|
||||
SELECT
|
||||
NULLIF(c.orderNo, '') AS order_id,
|
||||
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
|
||||
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
|
||||
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
|
||||
c.createdAt,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(c.orderNo, '')
|
||||
ORDER BY c.createdAt DESC, c.id DESC
|
||||
) AS rn
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
INNER JOIN #afs_orders AS o
|
||||
ON o.order_id = NULLIF(c.orderNo, '')
|
||||
)
|
||||
SELECT
|
||||
order_id,
|
||||
main_reason,
|
||||
second_reason,
|
||||
third_reason
|
||||
INTO #chat_latest
|
||||
FROM chat_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#afs_enriched') IS NOT NULL
|
||||
DROP TABLE #afs_enriched;
|
||||
|
||||
SELECT
|
||||
af.*,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN af.native_reason
|
||||
WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN cl.main_reason
|
||||
ELSE '未标注'
|
||||
END AS reason_l1,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(cl.second_reason, ''), '') <> '' THEN cl.second_reason
|
||||
ELSE '未标注'
|
||||
END AS reason_l2,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(cl.third_reason, ''), '') <> '' THEN cl.third_reason
|
||||
ELSE '未标注'
|
||||
END AS reason_l3,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN 'afs_reason'
|
||||
WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN 'chat_main_reason'
|
||||
ELSE 'unknown'
|
||||
END AS reason_source
|
||||
INTO #afs_enriched
|
||||
FROM #afs AS af
|
||||
LEFT JOIN #chat_latest AS cl
|
||||
ON cl.order_id = af.order_id;
|
||||
|
||||
-- 2) 最近N天产品出货/售后件数(件数优先)
|
||||
SELECT TOP (@limit)
|
||||
ae.sku_id,
|
||||
MAX(COALESCE(ms.sku_name, ae.sku_name)) AS sku_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT ae.order_id) AS order_count,
|
||||
SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum,
|
||||
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
|
||||
FROM #afs_enriched AS ae
|
||||
LEFT JOIN #matched_sku AS ms
|
||||
ON ms.sku_id = ae.sku_id
|
||||
GROUP BY ae.sku_id
|
||||
ORDER BY shipped_qty_sum DESC, order_count DESC;
|
||||
|
||||
-- 3) 最近N天该产品的店铺分布(件数)
|
||||
SELECT TOP (@limit)
|
||||
ae.shop_name,
|
||||
COUNT(DISTINCT ae.order_id) AS order_count,
|
||||
SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum,
|
||||
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
|
||||
FROM #afs_enriched AS ae
|
||||
GROUP BY ae.shop_name
|
||||
ORDER BY shipped_qty_sum DESC, order_count DESC;
|
||||
|
||||
IF OBJECT_ID('tempdb..#freight') IS NOT NULL
|
||||
DROP TABLE #freight;
|
||||
|
||||
SELECT
|
||||
NULLIF(f.sku_id, '') AS sku_id,
|
||||
NULLIF(f.so_id, '') AS so_id,
|
||||
ISNULL(NULLIF(f.receiver_state, ''), '未知') AS receiver_state,
|
||||
CASE
|
||||
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
|
||||
IN ('上海','江苏','浙江','安徽') THEN '一区'
|
||||
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
|
||||
IN ('北京','天津','河北','山东','河南','湖北','湖南','江西','福建','广东') THEN '二区'
|
||||
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
|
||||
IN ('辽宁','吉林','黑龙江','山西','陕西','四川','重庆','贵州','云南','广西','海南','内蒙古','甘肃','宁夏','青海','新疆','西藏') THEN '三区'
|
||||
ELSE '未分区'
|
||||
END AS freight_zone,
|
||||
ISNULL(NULLIF(f.expressName, ''), 'unknown') AS express_name,
|
||||
TRY_CONVERT(decimal(18, 2), f.freightCost) AS freight_cost,
|
||||
TRY_CONVERT(decimal(18, 2), f.weight) AS freight_weight,
|
||||
f.send_date
|
||||
INTO #freight
|
||||
FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK)
|
||||
WHERE f.send_date >= @start_time
|
||||
AND f.send_date < @end_time
|
||||
AND ISNULL(NULLIF(f.sku_id, ''), '') <> ''
|
||||
AND NULLIF(f.sku_id, '') IN (SELECT sku_id FROM #matched_sku);
|
||||
|
||||
-- 4) 最近N天一区/二区/三区运费(按SKU)
|
||||
SELECT TOP (@limit)
|
||||
fr.sku_id,
|
||||
fr.freight_zone,
|
||||
COUNT(*) AS freight_record_count,
|
||||
COUNT(DISTINCT fr.so_id) AS order_count,
|
||||
SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum,
|
||||
AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost,
|
||||
AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight
|
||||
FROM #freight AS fr
|
||||
GROUP BY fr.sku_id, fr.freight_zone
|
||||
ORDER BY
|
||||
CASE fr.freight_zone WHEN '一区' THEN 1 WHEN '二区' THEN 2 WHEN '三区' THEN 3 ELSE 9 END,
|
||||
order_count DESC;
|
||||
|
||||
-- 5) 最近N天省份运费TOP(按SKU)
|
||||
SELECT TOP (@limit)
|
||||
fr.sku_id,
|
||||
fr.receiver_state,
|
||||
COUNT(DISTINCT fr.so_id) AS order_count,
|
||||
SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum,
|
||||
AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost,
|
||||
AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight
|
||||
FROM #freight AS fr
|
||||
GROUP BY fr.sku_id, fr.receiver_state
|
||||
ORDER BY order_count DESC, freight_cost_sum DESC;
|
||||
|
||||
-- 6) 最近N天售后总览(该SKU)
|
||||
SELECT TOP (@limit)
|
||||
ae.sku_id,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT ae.order_id) AS afs_order_count,
|
||||
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(ae.pre_ship_apply_qty, 0) + ISNULL(ae.post_ship_apply_qty, 0)) AS apply_qty_sum,
|
||||
SUM(ISNULL(ae.pre_ship_return_qty, 0) + ISNULL(ae.post_ship_return_qty, 0)) AS return_qty_sum,
|
||||
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count,
|
||||
SUM(ISNULL(ae.refund_amount, 0)) AS refund_amount_sum
|
||||
FROM #afs_enriched AS ae
|
||||
GROUP BY ae.sku_id
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 7) 最近N天售后类型/结果矩阵(该SKU)
|
||||
SELECT TOP (@limit)
|
||||
ae.sku_id,
|
||||
ae.afs_type,
|
||||
ae.afs_result,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT ae.order_id) AS order_count,
|
||||
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
|
||||
FROM #afs_enriched AS ae
|
||||
GROUP BY ae.sku_id, ae.afs_type, ae.afs_result
|
||||
ORDER BY afs_record_count DESC, order_count DESC;
|
||||
|
||||
-- 8) 最近N天售后原因TOP(该SKU)
|
||||
SELECT TOP (@limit)
|
||||
ae.sku_id,
|
||||
ae.reason_l1,
|
||||
ae.reason_l2,
|
||||
ae.reason_l3,
|
||||
ae.reason_source,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT ae.order_id) AS order_count,
|
||||
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
|
||||
FROM #afs_enriched AS ae
|
||||
GROUP BY ae.sku_id, ae.reason_l1, ae.reason_l2, ae.reason_l3, ae.reason_source
|
||||
ORDER BY afs_record_count DESC, order_count DESC;
|
||||
|
||||
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
|
||||
DROP TABLE #inv_latest;
|
||||
|
||||
WITH inv_rank AS (
|
||||
SELECT
|
||||
NULLIF(i.sku_id, '') AS sku_id,
|
||||
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
|
||||
i.wms_co_id,
|
||||
i.name AS inventory_note,
|
||||
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(i.sku_id, '')
|
||||
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
|
||||
) AS rn
|
||||
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
|
||||
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku)
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
stock_qty,
|
||||
order_lock_qty,
|
||||
pick_lock_qty,
|
||||
lock_qty,
|
||||
virtual_qty,
|
||||
defective_qty,
|
||||
purchase_qty,
|
||||
return_qty,
|
||||
in_qty,
|
||||
min_qty,
|
||||
max_qty,
|
||||
wms_co_id,
|
||||
inventory_note,
|
||||
inventory_snapshot_time,
|
||||
CASE
|
||||
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
|
||||
THEN CAST(0 AS decimal(18, 2))
|
||||
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
|
||||
END AS available_qty
|
||||
INTO #inv_latest
|
||||
FROM inv_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
|
||||
DROP TABLE #ship_base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined12, '') AS product_id,
|
||||
NULLIF(a.undefined11, '') AS style_code_from_afs,
|
||||
NULLIF(a.undefined96, '') AS product_no_from_afs,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
|
||||
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
|
||||
ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道')
|
||||
END AS shop_channel,
|
||||
ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty
|
||||
INTO #ship_base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku)
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL
|
||||
DROP TABLE #ship_daily;
|
||||
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
CAST(sb.ship_dt AS date) AS ship_date,
|
||||
COUNT(DISTINCT sb.order_id) AS order_count,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum
|
||||
INTO #ship_daily
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
GROUP BY sb.sku_id, CAST(sb.ship_dt AS date);
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
|
||||
DROP TABLE #ship_stats;
|
||||
|
||||
SELECT
|
||||
sd.sku_id,
|
||||
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d,
|
||||
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d,
|
||||
MAX(sd.ship_date) AS last_ship_date
|
||||
INTO #ship_stats
|
||||
FROM #ship_daily AS sd
|
||||
GROUP BY sd.sku_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL
|
||||
DROP TABLE #sku_owner;
|
||||
|
||||
SELECT
|
||||
ms.sku_id,
|
||||
COALESCE(
|
||||
NULLIF(pc_exact.purchaser, ''),
|
||||
NULLIF(pc_style.purchaser, ''),
|
||||
NULLIF(pc_base.purchaser, ''),
|
||||
'未分配'
|
||||
) AS purchaser_name,
|
||||
COALESCE(
|
||||
NULLIF(po_exact.oper, ''),
|
||||
NULLIF(po_style.oper, ''),
|
||||
NULLIF(po_base.oper, ''),
|
||||
NULLIF(pc_exact.oper, ''),
|
||||
NULLIF(pc_style.oper, ''),
|
||||
NULLIF(pc_base.oper, ''),
|
||||
'未分配'
|
||||
) AS spu_oper_name
|
||||
INTO #sku_owner
|
||||
FROM #matched_sku AS ms
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = NULLIF(ms.spu, '')
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_exact
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = NULLIF(ms.style_code, '')
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_style
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = CASE
|
||||
WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0
|
||||
THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_base
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = NULLIF(ms.spu, '')
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_exact
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = NULLIF(ms.style_code, '')
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_style
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = CASE
|
||||
WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0
|
||||
THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_base;
|
||||
|
||||
IF OBJECT_ID('tempdb..#tm_oper') IS NOT NULL
|
||||
DROP TABLE #tm_oper;
|
||||
|
||||
WITH tm_rank AS (
|
||||
SELECT
|
||||
NULLIF(t.shopproid, '') AS product_id,
|
||||
ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name,
|
||||
NULLIF(t.oper, '') AS oper_name,
|
||||
NULLIF(t.dept, '') AS dept_name,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(t.shopproid, ''), ISNULL(NULLIF(t.shopname, ''), 'unknown')
|
||||
ORDER BY t.Id DESC
|
||||
) AS rn
|
||||
FROM dbo.op_TargetManagement AS t WITH (NOLOCK)
|
||||
WHERE NULLIF(t.shopproid, '') IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
product_id,
|
||||
shop_name,
|
||||
oper_name,
|
||||
dept_name
|
||||
INTO #tm_oper
|
||||
FROM tm_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#tm_oper_pid') IS NOT NULL
|
||||
DROP TABLE #tm_oper_pid;
|
||||
|
||||
SELECT
|
||||
product_id,
|
||||
MAX(oper_name) AS oper_name,
|
||||
MAX(dept_name) AS dept_name
|
||||
INTO #tm_oper_pid
|
||||
FROM #tm_oper
|
||||
GROUP BY product_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#product_id_contact') IS NOT NULL
|
||||
DROP TABLE #product_id_contact;
|
||||
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
ms.sku_name,
|
||||
ISNULL(NULLIF(sb.product_id, ''), '(未标注)') AS product_id,
|
||||
sb.shop_channel,
|
||||
sb.shop_name,
|
||||
COALESCE(
|
||||
NULLIF(tm_shop.oper_name, ''),
|
||||
NULLIF(tm_pid.oper_name, ''),
|
||||
NULLIF(ow.spu_oper_name, ''),
|
||||
'未分配'
|
||||
) AS operator_name,
|
||||
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name,
|
||||
COALESCE(NULLIF(ms.supplier_name, ''), '未标注') AS factory_name,
|
||||
COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name,
|
||||
COUNT(DISTINCT sb.order_id) AS order_count_30d,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d
|
||||
INTO #product_id_contact
|
||||
FROM #ship_base AS sb
|
||||
INNER JOIN #matched_sku AS ms
|
||||
ON ms.sku_id = sb.sku_id
|
||||
LEFT JOIN #sku_owner AS ow
|
||||
ON ow.sku_id = sb.sku_id
|
||||
LEFT JOIN #tm_oper AS tm_shop
|
||||
ON tm_shop.product_id = NULLIF(sb.product_id, '')
|
||||
AND tm_shop.shop_name = sb.shop_name
|
||||
LEFT JOIN #tm_oper_pid AS tm_pid
|
||||
ON tm_pid.product_id = NULLIF(sb.product_id, '')
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = sb.sku_id
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
AND ISNULL(NULLIF(sb.product_id, ''), '') <> ''
|
||||
GROUP BY
|
||||
sb.sku_id,
|
||||
ms.sku_name,
|
||||
ISNULL(NULLIF(sb.product_id, ''), '(未标注)'),
|
||||
sb.shop_channel,
|
||||
sb.shop_name,
|
||||
COALESCE(
|
||||
NULLIF(tm_shop.oper_name, ''),
|
||||
NULLIF(tm_pid.oper_name, ''),
|
||||
NULLIF(ow.spu_oper_name, ''),
|
||||
'未分配'
|
||||
),
|
||||
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配'),
|
||||
COALESCE(NULLIF(ms.supplier_name, ''), '未标注'),
|
||||
COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注');
|
||||
|
||||
IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL
|
||||
DROP TABLE #risk_eval;
|
||||
|
||||
SELECT
|
||||
ms.sku_id,
|
||||
ms.sku_name,
|
||||
inv.stock_qty,
|
||||
inv.order_lock_qty,
|
||||
inv.pick_lock_qty,
|
||||
inv.lock_qty,
|
||||
inv.available_qty,
|
||||
inv.virtual_qty,
|
||||
inv.defective_qty,
|
||||
inv.purchase_qty,
|
||||
inv.return_qty,
|
||||
inv.in_qty,
|
||||
inv.min_qty,
|
||||
inv.max_qty,
|
||||
inv.wms_co_id,
|
||||
inv.inventory_snapshot_time,
|
||||
ss.last_ship_date,
|
||||
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
|
||||
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
|
||||
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
|
||||
END AS decimal(18, 2)
|
||||
) AS estimated_stockout_days,
|
||||
10 AS replenishment_lead_days,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10 THEN 0
|
||||
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10) - inv.available_qty
|
||||
END AS decimal(18, 2)
|
||||
) AS suggest_replenish_qty_to_10d,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN '无库存快照'
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
|
||||
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 10 THEN '补货紧急(<=10天)'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN '补货预警(10-15天)'
|
||||
ELSE '库存安全'
|
||||
END AS stock_risk_level,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN 0
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty <= 0 THEN 1
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN 1
|
||||
ELSE 0
|
||||
END AS has_stock_risk
|
||||
INTO #risk_eval
|
||||
FROM #matched_sku AS ms
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = ms.sku_id
|
||||
LEFT JOIN #ship_stats AS ss
|
||||
ON ss.sku_id = ms.sku_id;
|
||||
|
||||
-- 9) 最近N天每日总发货件数(按SKU)
|
||||
SELECT TOP (@limit)
|
||||
sd.sku_id,
|
||||
ms.sku_name,
|
||||
sd.ship_date,
|
||||
sd.order_count,
|
||||
sd.shipped_qty_sum
|
||||
FROM #ship_daily AS sd
|
||||
LEFT JOIN #matched_sku AS ms
|
||||
ON ms.sku_id = sd.sku_id
|
||||
ORDER BY sd.ship_date DESC, sd.shipped_qty_sum DESC;
|
||||
|
||||
-- 10) 库存风险预估(按SKU,补货安全线=10天)
|
||||
SELECT TOP (@limit)
|
||||
re.sku_id,
|
||||
re.sku_name,
|
||||
re.stock_qty,
|
||||
re.order_lock_qty,
|
||||
re.pick_lock_qty,
|
||||
re.lock_qty,
|
||||
re.available_qty,
|
||||
re.virtual_qty,
|
||||
re.defective_qty,
|
||||
re.purchase_qty,
|
||||
re.return_qty,
|
||||
re.in_qty,
|
||||
re.min_qty,
|
||||
re.max_qty,
|
||||
re.wms_co_id,
|
||||
re.inventory_snapshot_time,
|
||||
re.last_ship_date,
|
||||
re.avg_daily_shipped_7d,
|
||||
re.avg_daily_shipped_30d,
|
||||
re.estimated_stockout_days,
|
||||
re.replenishment_lead_days,
|
||||
re.suggest_replenish_qty_to_10d,
|
||||
re.stock_risk_level
|
||||
FROM #risk_eval AS re
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN re.stock_qty IS NULL THEN 9
|
||||
WHEN re.avg_daily_shipped_30d <= 0 THEN 8
|
||||
WHEN re.available_qty <= 0 THEN 1
|
||||
WHEN re.estimated_stockout_days <= 10 THEN 2
|
||||
WHEN re.estimated_stockout_days <= 15 THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
re.estimated_stockout_days ASC,
|
||||
re.sku_id ASC;
|
||||
|
||||
-- 11) 近30天在售商品ID数量(按店铺渠道)
|
||||
SELECT TOP (@limit)
|
||||
sb.sku_id,
|
||||
ms.sku_name,
|
||||
sb.shop_channel,
|
||||
COUNT(DISTINCT sb.product_id) AS sold_product_id_count_30d,
|
||||
COUNT(DISTINCT sb.shop_name) AS shop_count_30d,
|
||||
COUNT(DISTINCT sb.order_id) AS order_count_30d,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d
|
||||
FROM #ship_base AS sb
|
||||
INNER JOIN #matched_sku AS ms
|
||||
ON ms.sku_id = sb.sku_id
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
AND ISNULL(NULLIF(sb.product_id, ''), '') <> ''
|
||||
GROUP BY sb.sku_id, ms.sku_name, sb.shop_channel
|
||||
ORDER BY sold_product_id_count_30d DESC, shipped_qty_sum_30d DESC, sb.shop_channel ASC;
|
||||
|
||||
-- 12) 商品ID对应店铺/渠道/运营人(用于通知)
|
||||
SELECT TOP (@limit)
|
||||
pic.sku_id,
|
||||
pic.sku_name,
|
||||
pic.product_id,
|
||||
pic.shop_channel,
|
||||
pic.shop_name,
|
||||
pic.operator_name,
|
||||
pic.purchaser_name,
|
||||
pic.factory_name,
|
||||
pic.warehouse_name,
|
||||
pic.order_count_30d,
|
||||
pic.shipped_qty_sum_30d
|
||||
FROM #product_id_contact AS pic
|
||||
ORDER BY pic.shipped_qty_sum_30d DESC, pic.order_count_30d DESC, pic.product_id ASC;
|
||||
|
||||
-- 13) 缺货风险通知清单(带采购/工厂/仓库/店铺/运营)
|
||||
SELECT TOP (@limit)
|
||||
re.sku_id,
|
||||
re.sku_name,
|
||||
re.stock_risk_level,
|
||||
re.available_qty,
|
||||
re.avg_daily_shipped_30d,
|
||||
re.estimated_stockout_days,
|
||||
re.suggest_replenish_qty_to_10d,
|
||||
pic.product_id,
|
||||
pic.shop_channel,
|
||||
pic.shop_name,
|
||||
pic.operator_name,
|
||||
pic.purchaser_name,
|
||||
pic.factory_name,
|
||||
pic.warehouse_name,
|
||||
pic.shipped_qty_sum_30d
|
||||
FROM #risk_eval AS re
|
||||
LEFT JOIN #product_id_contact AS pic
|
||||
ON pic.sku_id = re.sku_id
|
||||
WHERE re.has_stock_risk = 1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN re.available_qty <= 0 THEN 1
|
||||
WHEN re.estimated_stockout_days <= 10 THEN 2
|
||||
WHEN re.estimated_stockout_days <= 15 THEN 3
|
||||
ELSE 9
|
||||
END,
|
||||
re.estimated_stockout_days ASC,
|
||||
pic.shipped_qty_sum_30d DESC;
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Company product info lookup (SQLServer)")
|
||||
parser.add_argument("keyword", help="SKU/SPU/款式/产品名关键词")
|
||||
parser.add_argument("--days", type=int, default=30, help="lookback window, default 30")
|
||||
parser.add_argument("--limit", type=int, default=20, help="max rows per result set")
|
||||
args = parser.parse_args()
|
||||
|
||||
kw = args.keyword.strip()
|
||||
if not kw:
|
||||
print("keyword is required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
days = max(1, min(args.days, 365))
|
||||
limit = max(1, min(args.limit, 100))
|
||||
|
||||
sql = build_sql(kw, days, limit)
|
||||
cmd = ["company-db", "query", "--sql", sql]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "company-db failed").strip()
|
||||
print(err, file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
print(proc.stdout.rstrip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
404
product_id_promo_weekly_report.sql
Normal file
404
product_id_promo_weekly_report.sql
Normal file
@ -0,0 +1,404 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
/*
|
||||
Template: 商品ID推广费用周报(2026可用)
|
||||
目标: 费用 + 发货件数 + 缺货风险 + 负责人
|
||||
默认窗口: 上一个自然周 [week_start, week_end)
|
||||
*/
|
||||
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @lookback_30d datetime2 = DATEADD(day, -30, @week_end);
|
||||
DECLARE @lead_days int = 10;
|
||||
DECLARE @warn_days int = 15;
|
||||
|
||||
IF OBJECT_ID('tempdb..#base_week') IS NOT NULL
|
||||
DROP TABLE #base_week;
|
||||
|
||||
WITH src AS (
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined12, '') AS product_id,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
|
||||
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS shop_channel,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS promo_fee_8001201,
|
||||
(
|
||||
ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')), 0)
|
||||
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')), 0)
|
||||
) AS commission_fee_sum,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
)
|
||||
SELECT
|
||||
order_id,
|
||||
product_id,
|
||||
sku_id,
|
||||
shop_name,
|
||||
shop_channel,
|
||||
ship_dt,
|
||||
shipped_qty,
|
||||
ISNULL(promo_fee_8001201, 0) AS promo_fee_8001201,
|
||||
ISNULL(commission_fee_sum, 0) AS commission_fee_sum
|
||||
INTO #base_week
|
||||
FROM src
|
||||
WHERE ship_dt >= @week_start
|
||||
AND ship_dt < @week_end
|
||||
AND ISNULL(product_id, '') <> ''
|
||||
AND ISNULL(sku_id, '') <> ''
|
||||
AND order_labels NOT LIKE '%配件%'
|
||||
AND order_labels NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_30d') IS NOT NULL
|
||||
DROP TABLE #ship_30d;
|
||||
|
||||
WITH src_30d AS (
|
||||
SELECT
|
||||
NULLIF(a.undefined12, '') AS product_id,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
)
|
||||
SELECT
|
||||
product_id,
|
||||
sku_id,
|
||||
shop_name,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_30d
|
||||
INTO #ship_30d
|
||||
FROM src_30d
|
||||
WHERE ship_dt >= @lookback_30d
|
||||
AND ship_dt < @week_end
|
||||
AND ISNULL(product_id, '') <> ''
|
||||
AND ISNULL(sku_id, '') <> ''
|
||||
AND order_labels NOT LIKE '%配件%'
|
||||
AND order_labels NOT LIKE '%特殊单%'
|
||||
GROUP BY product_id, sku_id, shop_name;
|
||||
|
||||
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
|
||||
DROP TABLE #inv_latest;
|
||||
|
||||
WITH inv_rank AS (
|
||||
SELECT
|
||||
NULLIF(i.sku_id, '') AS sku_id,
|
||||
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
|
||||
i.name AS inventory_note,
|
||||
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(i.sku_id, '')
|
||||
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
|
||||
) AS rn
|
||||
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
|
||||
WHERE NULLIF(i.sku_id, '') IN (SELECT DISTINCT sku_id FROM #base_week)
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
stock_qty,
|
||||
order_lock_qty,
|
||||
pick_lock_qty,
|
||||
lock_qty,
|
||||
inventory_note,
|
||||
inventory_snapshot_time,
|
||||
CASE
|
||||
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
|
||||
THEN CAST(0 AS decimal(18, 2))
|
||||
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
|
||||
END AS available_qty
|
||||
INTO #inv_latest
|
||||
FROM inv_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_risk') IS NOT NULL
|
||||
DROP TABLE #sku_risk;
|
||||
|
||||
SELECT
|
||||
s.product_id,
|
||||
s.shop_name,
|
||||
s.sku_id,
|
||||
s.shipped_qty_30d,
|
||||
CAST(ISNULL(s.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
|
||||
inv.available_qty,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN NULL
|
||||
ELSE inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)
|
||||
END AS decimal(18, 2)
|
||||
) AS estimated_stockout_days,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty >= (ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
|
||||
ELSE ((ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
|
||||
END AS decimal(18, 2)
|
||||
) AS suggest_replenish_qty_to_10d,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN '无库存快照'
|
||||
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
|
||||
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)'
|
||||
ELSE '库存安全'
|
||||
END AS stock_risk_level
|
||||
INTO #sku_risk
|
||||
FROM #ship_30d AS s
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = s.sku_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#owner_pick') IS NOT NULL
|
||||
DROP TABLE #owner_pick;
|
||||
|
||||
WITH owner_src AS (
|
||||
SELECT
|
||||
bw.product_id,
|
||||
bw.shop_name,
|
||||
bw.sku_id,
|
||||
SUM(ISNULL(bw.shipped_qty, 0)) AS week_shipped_qty,
|
||||
NULLIF(js.other_1, '') AS spu,
|
||||
NULLIF(js.i_id, '') AS style_code,
|
||||
NULLIF(tm.oper, '') AS tm_oper,
|
||||
NULLIF(opm.oper, '') AS opm_oper,
|
||||
NULLIF(spu_cfg.oper, '') AS spu_cfg_oper,
|
||||
NULLIF(spu_cfg.purchaser, '') AS purchaser
|
||||
FROM #base_week AS bw
|
||||
LEFT JOIN dbo.jst_sku AS js WITH (NOLOCK)
|
||||
ON js.sku_id = bw.sku_id
|
||||
LEFT JOIN dbo.op_TargetManagement AS tm WITH (NOLOCK)
|
||||
ON NULLIF(tm.shopproid, '') = bw.product_id
|
||||
AND ISNULL(NULLIF(tm.shopname, ''), '') = ISNULL(NULLIF(bw.shop_name, ''), '')
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') IN (
|
||||
NULLIF(js.other_1, ''),
|
||||
NULLIF(js.i_id, ''),
|
||||
CASE
|
||||
WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0
|
||||
THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
ORDER BY o.Id DESC
|
||||
) AS opm
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.oper, c.purchaser
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') IN (
|
||||
NULLIF(js.other_1, ''),
|
||||
NULLIF(js.i_id, ''),
|
||||
CASE
|
||||
WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0
|
||||
THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
ORDER BY c.Id DESC
|
||||
) AS spu_cfg
|
||||
GROUP BY
|
||||
bw.product_id,
|
||||
bw.shop_name,
|
||||
bw.sku_id,
|
||||
NULLIF(js.other_1, ''),
|
||||
NULLIF(js.i_id, ''),
|
||||
NULLIF(tm.oper, ''),
|
||||
NULLIF(opm.oper, ''),
|
||||
NULLIF(spu_cfg.oper, ''),
|
||||
NULLIF(spu_cfg.purchaser, '')
|
||||
), owner_rank AS (
|
||||
SELECT
|
||||
product_id,
|
||||
shop_name,
|
||||
COALESCE(tm_oper, opm_oper, spu_cfg_oper, '未分配') AS operator_name,
|
||||
COALESCE(purchaser, '未分配') AS purchaser_name,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY product_id, shop_name
|
||||
ORDER BY week_shipped_qty DESC, sku_id ASC
|
||||
) AS rn
|
||||
FROM owner_src
|
||||
)
|
||||
SELECT
|
||||
product_id,
|
||||
shop_name,
|
||||
operator_name,
|
||||
purchaser_name
|
||||
INTO #owner_pick
|
||||
FROM owner_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#risk_agg') IS NOT NULL
|
||||
DROP TABLE #risk_agg;
|
||||
|
||||
SELECT
|
||||
product_id,
|
||||
shop_name,
|
||||
COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN sku_id END) AS urgent_risk_sku_count,
|
||||
COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货预警%' THEN sku_id END) AS warning_risk_sku_count,
|
||||
MIN(estimated_stockout_days) AS min_estimated_stockout_days,
|
||||
SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN ISNULL(suggest_replenish_qty_to_10d, 0) ELSE 0 END) AS suggest_replenish_qty_10d
|
||||
INTO #risk_agg
|
||||
FROM #sku_risk
|
||||
GROUP BY product_id, shop_name;
|
||||
|
||||
IF OBJECT_ID('tempdb..#product_week') IS NOT NULL
|
||||
DROP TABLE #product_week;
|
||||
|
||||
SELECT
|
||||
bw.product_id,
|
||||
bw.shop_channel,
|
||||
bw.shop_name,
|
||||
COUNT(DISTINCT bw.sku_id) AS sku_count,
|
||||
COUNT(DISTINCT bw.order_id) AS order_count,
|
||||
SUM(ISNULL(bw.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(bw.promo_fee_8001201, 0)) AS promo_fee_sum,
|
||||
SUM(ISNULL(bw.commission_fee_sum, 0)) AS commission_fee_sum,
|
||||
SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) AS marketing_fee_sum,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(bw.shipped_qty, 0)) <= 0 THEN NULL
|
||||
ELSE (SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) / SUM(ISNULL(bw.shipped_qty, 0)))
|
||||
END AS marketing_fee_per_piece,
|
||||
ISNULL(ra.urgent_risk_sku_count, 0) AS urgent_risk_sku_count,
|
||||
ISNULL(ra.warning_risk_sku_count, 0) AS warning_risk_sku_count,
|
||||
ra.min_estimated_stockout_days,
|
||||
ISNULL(ra.suggest_replenish_qty_10d, 0) AS suggest_replenish_qty_10d,
|
||||
CASE
|
||||
WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急'
|
||||
WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警'
|
||||
ELSE '库存安全/无快照'
|
||||
END AS product_stock_risk_level,
|
||||
ISNULL(op.purchaser_name, '未分配') AS purchaser_name,
|
||||
ISNULL(op.operator_name, '未分配') AS operator_name
|
||||
INTO #product_week
|
||||
FROM #base_week AS bw
|
||||
LEFT JOIN #risk_agg AS ra
|
||||
ON ra.product_id = bw.product_id
|
||||
AND ra.shop_name = bw.shop_name
|
||||
LEFT JOIN #owner_pick AS op
|
||||
ON op.product_id = bw.product_id
|
||||
AND op.shop_name = bw.shop_name
|
||||
GROUP BY
|
||||
bw.product_id,
|
||||
bw.shop_channel,
|
||||
bw.shop_name,
|
||||
ISNULL(ra.urgent_risk_sku_count, 0),
|
||||
ISNULL(ra.warning_risk_sku_count, 0),
|
||||
ra.min_estimated_stockout_days,
|
||||
ISNULL(ra.suggest_replenish_qty_10d, 0),
|
||||
CASE
|
||||
WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急'
|
||||
WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警'
|
||||
ELSE '库存安全/无快照'
|
||||
END,
|
||||
ISNULL(op.purchaser_name, '未分配'),
|
||||
ISNULL(op.operator_name, '未分配');
|
||||
|
||||
-- 1) 周汇总(商品ID维度)
|
||||
SELECT
|
||||
@week_start AS week_start,
|
||||
@week_end AS week_end,
|
||||
COUNT(*) AS product_shop_count,
|
||||
COUNT(DISTINCT product_id) AS product_count,
|
||||
COUNT(DISTINCT shop_name) AS shop_count,
|
||||
SUM(ISNULL(order_count, 0)) AS order_count_sum,
|
||||
SUM(ISNULL(shipped_qty_sum, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(promo_fee_sum, 0)) AS promo_fee_sum,
|
||||
SUM(ISNULL(commission_fee_sum, 0)) AS commission_fee_sum,
|
||||
SUM(ISNULL(marketing_fee_sum, 0)) AS marketing_fee_sum,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(shipped_qty_sum, 0)) <= 0 THEN NULL
|
||||
ELSE SUM(ISNULL(marketing_fee_sum, 0)) / SUM(ISNULL(shipped_qty_sum, 0))
|
||||
END AS marketing_fee_per_piece_avg,
|
||||
SUM(CASE WHEN product_stock_risk_level = '补货紧急' THEN 1 ELSE 0 END) AS urgent_product_count,
|
||||
SUM(CASE WHEN product_stock_risk_level = '补货预警' THEN 1 ELSE 0 END) AS warning_product_count
|
||||
FROM #product_week;
|
||||
|
||||
-- 2) 商品ID周报TOP100(费用 + 发货件数 + 缺货风险 + 负责人)
|
||||
SELECT TOP (100)
|
||||
product_id,
|
||||
shop_channel,
|
||||
shop_name,
|
||||
sku_count,
|
||||
order_count,
|
||||
shipped_qty_sum,
|
||||
promo_fee_sum,
|
||||
commission_fee_sum,
|
||||
marketing_fee_sum,
|
||||
marketing_fee_per_piece,
|
||||
product_stock_risk_level,
|
||||
min_estimated_stockout_days,
|
||||
suggest_replenish_qty_10d,
|
||||
urgent_risk_sku_count,
|
||||
warning_risk_sku_count,
|
||||
purchaser_name,
|
||||
operator_name
|
||||
FROM #product_week
|
||||
ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC, product_id ASC;
|
||||
|
||||
-- 3) 高风险商品ID清单(运营动作优先)
|
||||
SELECT TOP (100)
|
||||
product_id,
|
||||
shop_channel,
|
||||
shop_name,
|
||||
shipped_qty_sum,
|
||||
marketing_fee_sum,
|
||||
product_stock_risk_level,
|
||||
min_estimated_stockout_days,
|
||||
suggest_replenish_qty_10d,
|
||||
purchaser_name,
|
||||
operator_name
|
||||
FROM #product_week
|
||||
WHERE product_stock_risk_level IN ('补货紧急', '补货预警')
|
||||
ORDER BY
|
||||
CASE product_stock_risk_level WHEN '补货紧急' THEN 1 ELSE 2 END,
|
||||
min_estimated_stockout_days ASC,
|
||||
marketing_fee_sum DESC;
|
||||
|
||||
-- 4) 负责人缺失清单(数据治理)
|
||||
SELECT TOP (100)
|
||||
product_id,
|
||||
shop_channel,
|
||||
shop_name,
|
||||
shipped_qty_sum,
|
||||
marketing_fee_sum,
|
||||
purchaser_name,
|
||||
operator_name
|
||||
FROM #product_week
|
||||
WHERE purchaser_name = '未分配'
|
||||
OR operator_name = '未分配'
|
||||
ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC;
|
||||
BIN
remote_sync/.DS_Store
vendored
Normal file
BIN
remote_sync/.DS_Store
vendored
Normal file
Binary file not shown.
448
remote_sync/AGENTS.md
Normal file
448
remote_sync/AGENTS.md
Normal file
@ -0,0 +1,448 @@
|
||||
# AGENTS.md - Your Workspace
|
||||
|
||||
This folder is home. Treat it that way.
|
||||
|
||||
## Hard Output Rules
|
||||
|
||||
## No Pass-Through Policy
|
||||
|
||||
- Never throw unresolved problems back to the user with generic blockers like "无法访问"、"请提供平台"、"请先手动" as the first outcome.
|
||||
- Before asking the user for help, perform at least 2 autonomous recovery attempts (tool retry, alternate page/module, or fallback command).
|
||||
- If still blocked, report only concrete missing items (for example: OTP/CAPTCHA/2FA) and include the exact attempts already tried.
|
||||
- For market/sales tasks, do not ask for platform access details; this environment is preconfigured and should be executed directly.
|
||||
|
||||
## Sales Non-Compliance Guard
|
||||
|
||||
- For sales requests, these replies are forbidden: "没有直接访问", "请提供平台", "公开渠道", "无法连接系统".
|
||||
- Sales turn validity rule: at least one `exec` tool call must happen before final conclusion text.
|
||||
- For sales requests, do not send interim progress text; execute tools silently and return final results only.
|
||||
|
||||
## Critical Domain Lock
|
||||
|
||||
- For any request containing market-analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), only use domain `hq.yucekj.com` (or `yucekj.com`).
|
||||
- If the same request also includes company performance intent ("销售", "业绩", "售后", "退款", "费比", "店铺"), override to SQLServer route `company-sales-db-analysis` and do not open yuce.
|
||||
- In these requests, treat any other domain as blocked and do not use it for navigation or fetching.
|
||||
- Use browser tool only with valid actions (`open` or `navigate`) and always pass `targetUrl` set to `https://hq.yucekj.com` (or a yucekj subpage).
|
||||
- Preferred execution path for market-analysis: use `exec` to run `yuce-browser open https://hq.yucekj.com` then `yuce-browser snapshot 60`.
|
||||
- Never use browser action `search`.
|
||||
- Never use `web_search` or `web_fetch` for market-analysis requests.
|
||||
- `browser` and `nodes` tools are disabled; use `exec` with `yuce-browser` for yuce-only navigation.
|
||||
- Do not produce market-analysis numbers from memory. Always collect evidence from yucekj pages first.
|
||||
- Before writing any report file, complete at least one successful browser snapshot on `hq.yucekj.com` and base conclusions on that content.
|
||||
- If login/data collection fails, ask user to assist (OTP/CAPTCHA/manual step) instead of fabricating results.
|
||||
- If a tool request would hit another domain, abort that step and continue only with the yucekj domain.
|
||||
|
||||
## Xiaohongshu MCP Route
|
||||
|
||||
- For tasks mentioning "小红书"/"红书"/"xhs"/"笔记"/"种草"/"小红书数据", use local service `xiaohongshu-mcp` first.
|
||||
- Xiaohongshu tasks override generic market-analysis routing; do not route these tasks to yuce or sales systems.
|
||||
- Mandatory first actions for Xiaohongshu tasks:
|
||||
1) `exec`: `xhs-api health`
|
||||
2) `exec`: `xhs-api login-status`
|
||||
3) If not logged in: `exec`: `xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`, ask user to scan, then retry login status.
|
||||
- Data collection path:
|
||||
- Keyword retrieval: `xhs-api search "<keyword>"`
|
||||
- Feed detail: extract `id` + `xsecToken` from search result, then `xhs-api detail <id> <xsecToken> true`
|
||||
- Never ask user for Xiaohongshu platform URL/access basics first; this environment is preconfigured for local xiaohongshu-mcp.
|
||||
- Do not expose raw source addresses (domain/IP/API endpoint) in final user response.
|
||||
|
||||
## Critical Sales Route (High Priority)
|
||||
|
||||
- For any request containing sales intent keywords ("业绩", "销售", "店铺", "上周", "旗舰店", "费比", "售后", "退款"), immediately route to SQLServer data source `company-sales-db-analysis`.
|
||||
- Do not ask generic clarification before attempting data collection.
|
||||
- These sales/market routes are user-preapproved automation flows; do not ask for extra permission before running `exec` collection steps.
|
||||
- Do not call `memory_search` or `memory_get` for sales tasks.
|
||||
- Never call `tts` or `voice_call` for sales tasks unless user explicitly asks for voice output.
|
||||
- For sales tasks, do at least one `exec` data-collection tool call before any explanatory long text.
|
||||
- Do not say "没有权限/无法连接系统" for sales tasks; this environment is preconfigured with DB connection + credentials.
|
||||
- For sales tasks, never set `elevated=true` in `exec` calls.
|
||||
- For sales tasks, do not call custom scripts under `/root/.openclaw/workspace/scripts/` unless user explicitly asked and file existence was verified first.
|
||||
- KPI priority for sales/aftersale outputs: `发货件数/发货订单数/售后件数` first, then optional amount metrics.
|
||||
- Unless user explicitly asks for "金额/费用/收入/利润", avoid using amount as primary ranking dimension.
|
||||
- Mandatory first actions for sales tasks:
|
||||
1) `exec`: `company-db health`
|
||||
2) `exec`: `company-db tables`
|
||||
3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or execute targeted SQL)
|
||||
- Always read `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` before crafting complex sales/aftersale SQL.
|
||||
- If a sales request arrives, run tools immediately and output only the final result summary.
|
||||
|
||||
- For direct human chat, output final answer only; never output internal analysis, tool JSON, or pseudo code unless explicitly requested.
|
||||
- Default language is Simplified Chinese.
|
||||
- Keep answers short and practical; avoid templates and setup scripts unless user asks for them.
|
||||
- For greetings, reply with one short sentence only.
|
||||
- If message is exactly an internal relay line (for example `Agent-to-agent announce step`), reply with one short acknowledgment and wait.
|
||||
|
||||
|
||||
## First Run
|
||||
|
||||
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
|
||||
|
||||
## Every Session
|
||||
|
||||
Before doing anything else:
|
||||
|
||||
1. Read `SOUL.md` — this is who you are
|
||||
2. Read `USER.md` — this is who you're helping
|
||||
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
|
||||
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
|
||||
|
||||
Don't ask permission. Just do it.
|
||||
|
||||
## Memory
|
||||
|
||||
You wake up fresh each session. These files are your continuity:
|
||||
|
||||
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
|
||||
- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
|
||||
|
||||
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
|
||||
|
||||
### 🧠 MEMORY.md - Your Long-Term Memory
|
||||
|
||||
- **ONLY load in main session** (direct chats with your human)
|
||||
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
|
||||
- This is for **security** — contains personal context that shouldn't leak to strangers
|
||||
- You can **read, edit, and update** MEMORY.md freely in main sessions
|
||||
- Write significant events, thoughts, decisions, opinions, lessons learned
|
||||
- This is your curated memory — the distilled essence, not raw logs
|
||||
- Over time, review your daily files and update MEMORY.md with what's worth keeping
|
||||
|
||||
### 📝 Write It Down - No "Mental Notes"!
|
||||
|
||||
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
|
||||
- "Mental notes" don't survive session restarts. Files do.
|
||||
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
|
||||
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
- **Text > Brain** 📝
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
- Don't run destructive commands without asking.
|
||||
- `trash` > `rm` (recoverable beats gone forever)
|
||||
- When in doubt, ask.
|
||||
|
||||
## External vs Internal
|
||||
|
||||
**Safe to do freely:**
|
||||
|
||||
- Read files, explore, organize, learn
|
||||
- Search the web, check calendars
|
||||
- Work within this workspace
|
||||
|
||||
**Ask first:**
|
||||
|
||||
- Sending emails, tweets, public posts
|
||||
- Anything that leaves the machine
|
||||
- Anything you're uncertain about
|
||||
|
||||
Preapproved exception:
|
||||
- For `hq.yucekj.com` and SQLServer sales data tasks explicitly requested by the human, proceed directly with execution and report results.
|
||||
|
||||
## Group Chats
|
||||
|
||||
You have access to your human's stuff. That doesn't mean you _share_ their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
|
||||
|
||||
### 💬 Know When to Speak!
|
||||
|
||||
In group chats where you receive every message, be **smart about when to contribute**:
|
||||
|
||||
**Respond when:**
|
||||
|
||||
- Directly mentioned or asked a question
|
||||
- You can add genuine value (info, insight, help)
|
||||
- Something witty/funny fits naturally
|
||||
- Correcting important misinformation
|
||||
- Summarizing when asked
|
||||
|
||||
**Stay silent (HEARTBEAT_OK) when:**
|
||||
|
||||
- It's just casual banter between humans
|
||||
- Someone already answered the question
|
||||
- Your response would just be "yeah" or "nice"
|
||||
- The conversation is flowing fine without you
|
||||
- Adding a message would interrupt the vibe
|
||||
|
||||
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
|
||||
|
||||
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
|
||||
|
||||
**📝 Platform Formatting:**
|
||||
|
||||
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
|
||||
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
|
||||
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
|
||||
|
||||
## 💓 Heartbeats - Be Proactive!
|
||||
|
||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
### Heartbeat vs Cron: When to Use Each
|
||||
|
||||
**Use heartbeat when:**
|
||||
|
||||
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
|
||||
- You need conversational context from recent messages
|
||||
- Timing can drift slightly (every ~30 min is fine, not exact)
|
||||
- You want to reduce API calls by combining periodic checks
|
||||
|
||||
**Use cron when:**
|
||||
|
||||
- Exact timing matters ("9:00 AM sharp every Monday")
|
||||
- Task needs isolation from main session history
|
||||
- You want a different model or thinking level for the task
|
||||
- One-shot reminders ("remind me in 20 minutes")
|
||||
- Output should deliver directly to a channel without main session involvement
|
||||
|
||||
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
|
||||
|
||||
**Things to check (rotate through these, 2-4 times per day):**
|
||||
|
||||
- **Emails** - Any urgent unread messages?
|
||||
- **Calendar** - Upcoming events in next 24-48h?
|
||||
- **Mentions** - Twitter/social notifications?
|
||||
- **Weather** - Relevant if your human might go out?
|
||||
|
||||
**Track your checks** in `memory/heartbeat-state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"lastChecks": {
|
||||
"email": 1703275200,
|
||||
"calendar": 1703260800,
|
||||
"weather": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to reach out:**
|
||||
|
||||
- Important email arrived
|
||||
- Calendar event coming up (<2h)
|
||||
- Something interesting you found
|
||||
- It's been >8h since you said anything
|
||||
|
||||
**When to stay quiet (HEARTBEAT_OK):**
|
||||
|
||||
- Late night (23:00-08:00) unless urgent
|
||||
- Human is clearly busy
|
||||
- Nothing new since last check
|
||||
- You just checked <30 minutes ago
|
||||
|
||||
**Proactive work you can do without asking:**
|
||||
|
||||
- Read and organize memory files
|
||||
- Check on projects (git status, etc.)
|
||||
- Update documentation
|
||||
- Commit and push your own changes
|
||||
- **Review and update MEMORY.md** (see below)
|
||||
|
||||
### 🔄 Memory Maintenance (During Heartbeats)
|
||||
|
||||
Periodically (every few days), use a heartbeat to:
|
||||
|
||||
1. Read through recent `memory/YYYY-MM-DD.md` files
|
||||
2. Identify significant events, lessons, or insights worth keeping long-term
|
||||
3. Update `MEMORY.md` with distilled learnings
|
||||
4. Remove outdated info from MEMORY.md that's no longer relevant
|
||||
|
||||
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
|
||||
|
||||
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
|
||||
|
||||
## Make It Yours
|
||||
|
||||
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
|
||||
|
||||
## Clean Chat Mode
|
||||
|
||||
- In direct human chats, answer the user request only and keep replies concise.
|
||||
- Do not output internal reasoning or planning text.
|
||||
- Ignore plain system relay lines like `Agent-to-agent announce step`; do not ask follow-up unless a real task is present.
|
||||
- If a required argument is missing for a tool action, ask for that one missing field in one short sentence.
|
||||
|
||||
## Search Fallback
|
||||
|
||||
- When user asks for online search and `web_search` is unavailable (for example missing API key), use `browser` to open a search engine and summarize results instead of returning setup instructions.
|
||||
- Return concise Chinese results first, then optional source links.
|
||||
|
||||
## Market Analysis Hard Route
|
||||
|
||||
- If user request mentions market analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis` in `/root/.openclaw/workspace/websites.yaml`.
|
||||
- For this route, do not start with `web_search` or generic search engines.
|
||||
- Canonical market site is `https://hq.yucekj.com` (login entry: `https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F`).
|
||||
- Start from login entry, then continue in the same site session for evidence collection.
|
||||
- Use env credentials `SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME` and `SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD` first.
|
||||
|
||||
## Company Sales Hard Route
|
||||
|
||||
- If user request mentions sales-data intent ("销售数据", "销售报表", "销售看板", "公司销售", "业绩报表", "费比监控", "业绩数据", "售后", "退款"), route to `company-sales-db-analysis` in `/root/.openclaw/workspace/websites.yaml`.
|
||||
- For this route, do not use generic search first. Start with `exec`: `company-db health`, then run SQL query.
|
||||
- Allowed data source is SQLServer tables listed in `/root/.openclaw/workspace/websites.yaml` -> `company-sales-db-analysis.allowed_tables`.
|
||||
- For sales-data requests, never call `memory_search` or `memory_get`; go straight to DB execution.
|
||||
- Use env credentials `COMPANY_DB_HOST/PORT/NAME/USER/PASSWORD` (loaded from `/root/.openclaw/credentials/company-sql.env`).
|
||||
- If request contains both market-analysis and sales-data keywords, prioritize company sales DB route when the ask is company performance/aftersale metrics.
|
||||
|
||||
## Install Media Hard Route
|
||||
|
||||
- If user request mentions install-media intent ("安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), route to `install-media-db-analysis` in `/root/.openclaw/workspace/websites.yaml`.
|
||||
- Execution must be: order-id -> product_no (SQLServer) -> install-media links (MySQL), command:
|
||||
- `install-media-lookup --order-id "<订单号>"`
|
||||
- Keep both fields in output:
|
||||
- `raw_path` (数据库原始路径)
|
||||
- `media_url` (浏览器可访问地址)
|
||||
- URL rule: `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`.
|
||||
- If MySQL is unavailable or no mapping hit, return explicit gap and next step; do not fabricate links.
|
||||
|
||||
## Company Sales Task Router
|
||||
|
||||
- For `company-sales-db-analysis`, use field catalog from `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`.
|
||||
- Mandatory execution sequence: identify指标 -> map字段/表 -> run SQL -> check null/outlier -> output conclusions.
|
||||
- For aftersale analysis requests (`售后分析`/`退款原因`/`退货原因`/`客诉原因`), always run:
|
||||
`company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql`
|
||||
- Do not output "售后分析" using only `fys_Ticketing_install` work-order metrics.
|
||||
- If a legacy path references `sqlserver/last_week_after_sale_analysis.sql`, treat it as alias of `after_sale_360_report.sql` and still output reason dimensions.
|
||||
- Output order for sales/aftersale reports:
|
||||
1) 发货件数/售后件数/订单数
|
||||
2) 结构占比与趋势
|
||||
3) 金额类指标(仅补充,不作主排序,除非用户明确要求)
|
||||
- Dimension routing defaults:
|
||||
- fee ratio / margin / refund / expense / platform or shop performance -> `jst_goods_profit_kpi_items_afs` + `jst_order_express_details`
|
||||
- SKUID/SPU/detail requests -> `jst_sku` + `op_TargetManagement` + `op_operManage`
|
||||
- logistics/freight questions -> `fys_jstFreightCost` + `fys_jstFreightCostDetail`
|
||||
- installation/aftersale process -> `fys_Ticketing_install` + `fys_Ticketing_install_item` + `pc_kfChatLog`
|
||||
- retention decision (退回/补偿/放货) -> `product-retention-decision --depreciation-rate 0.20`
|
||||
- For retention decision output, must include:
|
||||
- `depreciation_rate_used` + `depreciation_loss_if_return`
|
||||
- `option_return_net_profit` / `option_discount_net_profit_{low,high}` / `option_release_net_profit`
|
||||
- `recommended_option` + `recommended_action`
|
||||
- Evidence minimum for sales brief:
|
||||
- at least 1 SQL result set with fields + values
|
||||
- at least 1 statement of time window and filter conditions
|
||||
- if values are `--` or empty, report as `未在当前筛选下取到`
|
||||
|
||||
## Autonomous Page Planning (Market Analysis)
|
||||
|
||||
Priority: This section overrides earlier conflicting market-analysis guidance.
|
||||
|
||||
- For market-analysis requests, the agent must autonomously decide which yuce module/page to open based on requested metrics.
|
||||
- Allowed domain is still strictly limited to `hq.yucekj.com` and `yucekj.com`.
|
||||
- Execution path must use `exec` with `yuce-browser` only (no `cron`, no `web_search`, no `web_fetch`).
|
||||
- Page-by-page data fields and button-expansion flow must follow:
|
||||
- `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`
|
||||
- Do not use guessed hash routes for modules (forbidden examples: `/#/insight`, `/#/leaf-category-analysis`, `/#/keyword-analysis`, `/#/trend-analysis`, `/#/demand-analysis`, `/#/keyword-layout`, `/#/brand-shop-hot`, `/#/price-segment`, `/#/surge-product`).
|
||||
- For module access, use either homepage card clicks or known `?report-app=...` report URLs.
|
||||
- If a page shows `很抱歉,您没有权限访问此页面`, retry once via homepage card click/known report URL before concluding permission is missing.
|
||||
|
||||
### Metric -> Page Routing Map
|
||||
|
||||
- If request includes market size / growth / trend -> prioritize `市场洞察`.
|
||||
- If request includes category share / subcategory opportunity -> prioritize `叶子类目分析`.
|
||||
- If request includes brand/shop/product competition -> prioritize `品牌店铺商品热榜` + `竞店布局分析`.
|
||||
- If request includes user demand / keyword trend -> prioritize `产品需求分析` + `需求趋势分析` + `关键词布局分析`.
|
||||
- If request includes pricing strategy / price band -> prioritize `价格段布局` + `品牌布局分析`.
|
||||
|
||||
### Autonomous Execution Policy
|
||||
|
||||
- First collect evidence pages, then output conclusions.
|
||||
- Minimum evidence for each report: at least 2 snapshots from relevant modules + key request logs.
|
||||
- If requested metric has no visible data on current account/plan, explicitly mark as "未在当前账号页面取到" (do not fabricate).
|
||||
- If CAPTCHA/OTP appears, stop immediately and ask user for manual step.
|
||||
|
||||
### Output Format (Mandatory)
|
||||
|
||||
- Section 1: "已抓取证据" (page/module names + observed fields)
|
||||
- Section 2: "分析结论" (only based on Section 1)
|
||||
- Section 3: "缺失项与下一步" (what still needs manual click/filter or higher permissions)
|
||||
|
||||
## Market Analysis Output Guardrails
|
||||
|
||||
- For yuce market-analysis tasks, module names must be chosen only from this whitelist:
|
||||
- 市场洞察
|
||||
- 叶子类目分析
|
||||
- 品牌店铺商品热榜
|
||||
- 飙升品榜单
|
||||
- 品牌布局分析
|
||||
- 竞店布局分析
|
||||
- 价格段布局
|
||||
- 产品需求分析
|
||||
- 需求趋势分析
|
||||
- 关键词布局分析
|
||||
- Do not invent module ids, dataset names, or pseudo data-source names (e.g. yuce_market_size).
|
||||
- If not executed yet, output only "拟访问模块(白名单)" + "对应指标"; do not output any numeric conclusions.
|
||||
- Do not edit files under `memory/` unless user explicitly asks to modify memory files.
|
||||
|
||||
## Market Brief Mode (Conclusion + Suggestions)
|
||||
|
||||
For market-analysis tasks, final output must be an analysis brief, not a tool plan.
|
||||
|
||||
### Mandatory evidence gate
|
||||
|
||||
- Before writing any conclusion, execute evidence collection with `exec` + `yuce-browser`.
|
||||
- Minimum evidence:
|
||||
- at least 2 snapshots from relevant yuce modules
|
||||
- at least 1 network/request evidence line from `yuce-browser requests`
|
||||
- If evidence is insufficient, output only missing items and ask user for one concrete next action.
|
||||
|
||||
### Mandatory report structure
|
||||
|
||||
Use exactly these sections in Chinese:
|
||||
|
||||
1. `已抓取证据`
|
||||
- List each evidence item with: module/page, metric field, observed value.
|
||||
2. `核心结论`
|
||||
- 3-5 concise conclusions, each mapped to evidence.
|
||||
3. `可执行建议`
|
||||
- 3-5 actionable suggestions with priority (`P1/P2/P3`).
|
||||
4. `风险与缺口`
|
||||
- Explicitly state unavailable metrics and why.
|
||||
5. `下一步`
|
||||
- The next 1-3 actions to improve certainty.
|
||||
|
||||
### Anti-fabrication constraints
|
||||
|
||||
- Do not output market numbers unless they appear in captured evidence.
|
||||
- Any inference must be labeled as `推断`.
|
||||
- If a metric is absent, write `未在当前页面取到`.
|
||||
|
||||
## Market Brief Script Binding
|
||||
|
||||
For market-analysis brief tasks, prefer this deterministic command first:
|
||||
|
||||
- `exec`: `market-brief-report <keyword>`
|
||||
- During execution, enforce SOP steps in `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`,
|
||||
especially: after any date/filter change, click `查询` before collecting evidence.
|
||||
|
||||
Then summarize its output without changing numeric values.
|
||||
If script output already contains the required sections, reuse them directly.
|
||||
121
remote_sync/AGENTS.remote.md
Normal file
121
remote_sync/AGENTS.remote.md
Normal file
@ -0,0 +1,121 @@
|
||||
# AGENTS.md - OpenClaw Workspace Rules
|
||||
|
||||
## 1) Core Behavior
|
||||
|
||||
- Execute first, explain later. Do not throw generic blockers to the user.
|
||||
- Before asking user for help, try at least 2 autonomous recoveries.
|
||||
- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired).
|
||||
- Do not fabricate data. Every conclusion must come from tool results.
|
||||
|
||||
## 2) Routing Priority (Highest to Lowest)
|
||||
|
||||
### A. Xiaohongshu Tasks (override all market routes)
|
||||
|
||||
Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情
|
||||
|
||||
Required sequence:
|
||||
1. `exec: xhs-api health`
|
||||
2. `exec: xhs-api login-status`
|
||||
3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`
|
||||
4. Then use:
|
||||
- `xhs-api search "<keyword>"`
|
||||
- `xhs-api detail <id> <xsecToken> true`
|
||||
|
||||
### B. Sales / Aftersale / Refund / Performance Tasks (SQL only)
|
||||
|
||||
Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图
|
||||
|
||||
Hard rules:
|
||||
- Must route to `company-sales-db-analysis`.
|
||||
- Do not use yuce website for sales/aftersale tasks.
|
||||
- Do not use web_search/web_fetch for these tasks.
|
||||
- Must run at least one `exec` data query before final response.
|
||||
- Do not call `tts` unless user explicitly asks voice output.
|
||||
|
||||
Mandatory first checks (once per turn):
|
||||
1. `exec: company-db health`
|
||||
2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/`
|
||||
|
||||
Template mapping:
|
||||
- 店铺周报 -> `shop_weekly_report.sql`
|
||||
- SKU结构 -> `sku_structure_report.sql`
|
||||
- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000`
|
||||
|
||||
Product info output minimum fields:
|
||||
- SKU编码, SKU名称, 成本价, 材质, 预览图地址
|
||||
- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态
|
||||
- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天)
|
||||
- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)
|
||||
- 退款原因/售后 -> `after_sale_360_report.sql`
|
||||
- 费比异常 -> `fee_ratio_alert.sql`
|
||||
- 物流时效 -> `logistics_sla_report.sql`
|
||||
- 运费效率 -> `freight_efficiency_report.sql`
|
||||
- 客服热点 -> `customer_service_hotspot_report.sql`
|
||||
- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
|
||||
- 输出需包含:客服能力总分与等级(S/A/B/C/D)
|
||||
- 安装服务 -> `install_service_quality_report.sql`
|
||||
- 链接目标覆盖 -> `link_target_coverage_report.sql`
|
||||
|
||||
Aftersale special rule:
|
||||
- Do not analyze only one table.
|
||||
- Must prioritize reason dimensions from:
|
||||
1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56)
|
||||
2) `pc_kfChatLog` (mainReason/secondReason/thirdReason)
|
||||
3) `fys_Ticketing_install` (工单维度)
|
||||
- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions.
|
||||
|
||||
Metric priority rule (very important):
|
||||
- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`.
|
||||
- Amount metrics are secondary and optional.
|
||||
- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first.
|
||||
|
||||
Output format for sales/aftersale:
|
||||
1. 件数与订单核心结论
|
||||
2. 原因结构(一级/二级/三级)
|
||||
3. 趋势与异常
|
||||
4. 金额补充(可选)
|
||||
5. 可执行建议
|
||||
6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术”
|
||||
7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)”
|
||||
|
||||
### C. Market Analysis Tasks (non-sales)
|
||||
|
||||
Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析
|
||||
|
||||
Hard rules:
|
||||
- Only allow domain `hq.yucekj.com` / `yucekj.com`.
|
||||
- Use `exec` with `yuce-browser` only.
|
||||
- Never use browser `search` action.
|
||||
- Never use web_search/web_fetch for this route.
|
||||
- If login/CAPTCHA blocks, ask user for that exact step only.
|
||||
|
||||
### D. General Online Search Tasks
|
||||
|
||||
Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新
|
||||
|
||||
Preferred execution:
|
||||
1. First use `exec: web-search "<query>" --count 8` (stable path; includes Brave+Bing fallback).
|
||||
2. Optional: if user explicitly asks for Brave-only, use `web_search`.
|
||||
3. Summarize with source links, and mark date/time context when content is time-sensitive.
|
||||
|
||||
## 3) Security and Privacy
|
||||
|
||||
- Do not expose raw data source endpoints in user-facing replies.
|
||||
- Mask domain/IP/api endpoints in final messages when possible.
|
||||
- Never leak secrets from env files.
|
||||
|
||||
## 4) Reply Quality Rules
|
||||
|
||||
- Send concise final answer; no long tool transcript dump.
|
||||
- If task may take time, send one short processing hint first.
|
||||
- Do not repeat the same result twice in one turn.
|
||||
- If a request fails, include: what was attempted, what failed, and next actionable fix.
|
||||
|
||||
## 5) Workspace Paths
|
||||
|
||||
- SQL templates: `/root/.openclaw/workspace/sqlserver/`
|
||||
- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
|
||||
- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md`
|
||||
- Tool guide: `/root/.openclaw/workspace/TOOLS.md`
|
||||
- Site config: `/root/.openclaw/workspace/websites.yaml`
|
||||
123
remote_sync/README_SQL_TEMPLATES.remote.md
Normal file
123
remote_sync/README_SQL_TEMPLATES.remote.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 销售分析常用 SQL 模板包
|
||||
|
||||
数据源:`JSDataMiddlePlatform`(SQLServer)
|
||||
执行方式:`company-db run-file <sql文件路径>`
|
||||
|
||||
默认分析口径(已按最新要求):
|
||||
- 主口径优先:`发货件数 / 售后件数 / 订单数`
|
||||
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
|
||||
|
||||
## 模板列表
|
||||
|
||||
1. `shop_weekly_report.sql`
|
||||
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
2. `sku_structure_report.sql`
|
||||
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
|
||||
- 默认时间:最近30天
|
||||
|
||||
3. `refund_reason_report.sql`
|
||||
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
|
||||
- 默认时间:最近30天
|
||||
|
||||
4. `fee_ratio_alert.sql`
|
||||
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
|
||||
- 默认时间:最近14天
|
||||
|
||||
5. `logistics_sla_report.sql`
|
||||
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
|
||||
- 默认时间:最近30天
|
||||
|
||||
6. `freight_efficiency_report.sql`
|
||||
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
|
||||
- 默认时间:最近30天
|
||||
|
||||
7. `install_service_quality_report.sql`
|
||||
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
|
||||
- 默认时间:最近90天
|
||||
|
||||
8. `customer_service_hotspot_report.sql`
|
||||
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
|
||||
- 默认时间:最近30天
|
||||
|
||||
9. `link_target_coverage_report.sql`
|
||||
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
|
||||
- 默认时间:在线链接全量
|
||||
|
||||
10. `after_sale_360_report.sql`
|
||||
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
11. `product-info`(CLI辅助命令)
|
||||
- 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址)
|
||||
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 输出:
|
||||
- 结果集1:产品基础信息(含 `sku_name`、`cost_price`、`board_material`、`preview_pic_url`)
|
||||
- 结果集2:最近N天出货/售后件数
|
||||
- 结果集3:最近N天店铺分布(件数)
|
||||
- 结果集4:最近N天一区/二区/三区运费汇总
|
||||
- 结果集5:最近N天省份运费TOP
|
||||
- 结果集6:最近N天售后总览(申请件数/退回件数/退款单数)
|
||||
- 结果集7:最近N天售后类型/结果矩阵
|
||||
- 结果集8:最近N天售后原因TOP(含来源)
|
||||
|
||||
12. `product-skus-by-id`(CLI辅助命令)
|
||||
- 用途:按 商品ID 查询该ID下所有 SKU编码 与 SKU名称
|
||||
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
|
||||
- 输出:
|
||||
- 结果集1:匹配汇总(SKU数量/SPU数量)
|
||||
- 结果集2:SKU编码+SKU名称清单(含分类、成本、预览图)
|
||||
|
||||
13. `customer_service_experience_report.sql`
|
||||
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
|
||||
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
|
||||
|
||||
14. `customer-service-analysis`(CLI辅助命令)
|
||||
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
|
||||
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
- 输出:
|
||||
- 结果集1:时间段总览(会话量、空内容率、无对应分类率)
|
||||
- 结果集2:一级/二级问题结构 TOP
|
||||
- 结果集3:店铺体验分对比
|
||||
- 结果集4:话术覆盖率(问候/共情/承诺/收尾)
|
||||
- 结果集5:风险话术命中 TOP
|
||||
- 结果集6:每日体验趋势
|
||||
- 结果集7:风险对话案例 + 建议回复话术
|
||||
- 结果集8:客服能力评分(响应能力/满意度/退款效率/求助风险控制 + 总分等级)
|
||||
- 结果集9:按客服名字评分(可识别优秀客服)
|
||||
|
||||
### 客服能力评分口径(代理映射)
|
||||
|
||||
- 参考维度:旺旺响应能力、旺旺满意度、退款处理体验、平台求助风险控制
|
||||
- 当前数据库可用字段:`pc_kfChatLog.content/mainReason/secondReason/thirdReason/reasonContent`
|
||||
- 评分权重(总分100):
|
||||
- 响应能力 30%
|
||||
- 满意度 30%
|
||||
- 退款效率 20%
|
||||
- 求助风险控制 20%
|
||||
- 等级:`S>=90`,`A>=80`,`B>=70`,`C>=60`,`D<60`
|
||||
- 说明:该评分为“规则维度代理口径”,用于内部运营诊断,不等同平台官方最终分数。
|
||||
|
||||
## 运行示例
|
||||
|
||||
```bash
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_experience_report.sql
|
||||
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。
|
||||
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
|
||||
- 如需自定义时间,修改每个模板顶部变量即可。
|
||||
53
remote_sync/TOOLS.md
Normal file
53
remote_sync/TOOLS.md
Normal file
@ -0,0 +1,53 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
|
||||
|
||||
## What Goes Here
|
||||
|
||||
Things like:
|
||||
|
||||
- Camera names and locations
|
||||
- SSH hosts and aliases
|
||||
- Preferred voices for TTS
|
||||
- Speaker/room names
|
||||
- Device nicknames
|
||||
- Anything environment-specific
|
||||
|
||||
## Examples
|
||||
|
||||
```markdown
|
||||
### Cameras
|
||||
|
||||
- living-room → Main area, 180° wide angle
|
||||
- front-door → Entrance, motion-triggered
|
||||
|
||||
### SSH
|
||||
|
||||
- home-server → 192.168.1.100, user: admin
|
||||
|
||||
### TTS
|
||||
|
||||
- Preferred voice: "Nova" (warm, slightly British)
|
||||
- Default speaker: Kitchen HomePod
|
||||
```
|
||||
|
||||
## Why Separate?
|
||||
|
||||
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
|
||||
|
||||
---
|
||||
|
||||
Add whatever helps you do your job. This is your cheat sheet.
|
||||
|
||||
## Web Routing Preset (Auto)
|
||||
|
||||
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
|
||||
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
|
||||
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
|
||||
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
|
||||
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
|
||||
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
|
||||
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
|
||||
- If task does not need login, use `public-search` or direct public site access.
|
||||
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
|
||||
- Never operate outside `allowed_domains`.
|
||||
73
remote_sync/TOOLS.remote.md
Normal file
73
remote_sync/TOOLS.remote.md
Normal file
@ -0,0 +1,73 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
|
||||
|
||||
## What Goes Here
|
||||
|
||||
Things like:
|
||||
|
||||
- Camera names and locations
|
||||
- SSH hosts and aliases
|
||||
- Preferred voices for TTS
|
||||
- Speaker/room names
|
||||
- Device nicknames
|
||||
- Anything environment-specific
|
||||
|
||||
## Examples
|
||||
|
||||
```markdown
|
||||
### Cameras
|
||||
|
||||
- living-room → Main area, 180° wide angle
|
||||
- front-door → Entrance, motion-triggered
|
||||
|
||||
### SSH
|
||||
|
||||
- home-server → 192.168.1.100, user: admin
|
||||
|
||||
### TTS
|
||||
|
||||
- Preferred voice: "Nova" (warm, slightly British)
|
||||
- Default speaker: Kitchen HomePod
|
||||
```
|
||||
|
||||
## Why Separate?
|
||||
|
||||
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
|
||||
|
||||
---
|
||||
|
||||
Add whatever helps you do your job. This is your cheat sheet.
|
||||
|
||||
## Web Routing Preset (Auto)
|
||||
|
||||
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
|
||||
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
|
||||
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
|
||||
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
|
||||
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
|
||||
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
|
||||
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
|
||||
- If task does not need login, use `public-search` or direct public site access.
|
||||
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
|
||||
- Never operate outside `allowed_domains`.
|
||||
|
||||
## Product Info Helper
|
||||
|
||||
- Command: `product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- Returns:
|
||||
1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)
|
||||
2) Last N days shipped/aftersale counts
|
||||
3) Last N days shop distribution by shipped piece count
|
||||
- Data source: SQLServer `JSDataMiddlePlatform`
|
||||
- Command: `product-skus-by-id "<商品id>" --limit 1000`
|
||||
- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid).
|
||||
- `product-info` now also returns aftersale insight for matched SKU:
|
||||
- aftersale summary, type/result matrix, reason TOP (last N days)
|
||||
|
||||
|
||||
## Customer Service Experience Helper
|
||||
|
||||
- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
- Purpose: Analyze customer feedback experience + service wording quality in a date range.
|
||||
- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D).
|
||||
BIN
remote_sync/__pycache__/bot.cpython-313.pyc
Normal file
BIN
remote_sync/__pycache__/bot.cpython-313.pyc
Normal file
Binary file not shown.
BIN
remote_sync/__pycache__/bot.current.cpython-313.pyc
Normal file
BIN
remote_sync/__pycache__/bot.current.cpython-313.pyc
Normal file
Binary file not shown.
BIN
remote_sync/__pycache__/bot.py.cpython-313.pyc
Normal file
BIN
remote_sync/__pycache__/bot.py.cpython-313.pyc
Normal file
Binary file not shown.
BIN
remote_sync/__pycache__/install_media_lookup.cpython-313.pyc
Normal file
BIN
remote_sync/__pycache__/install_media_lookup.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
2319
remote_sync/bot.current.py
Executable file
2319
remote_sync/bot.current.py
Executable file
File diff suppressed because it is too large
Load Diff
2389
remote_sync/bot.py
Executable file
2389
remote_sync/bot.py
Executable file
File diff suppressed because it is too large
Load Diff
2319
remote_sync/bot.py.minimax-work
Executable file
2319
remote_sync/bot.py.minimax-work
Executable file
File diff suppressed because it is too large
Load Diff
628
remote_sync/bot.remote.py
Executable file
628
remote_sync/bot.remote.py
Executable file
@ -0,0 +1,628 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
|
||||
from dingtalk_stream import Credential, DingTalkStreamClient
|
||||
|
||||
|
||||
MARKET_HINT_KEYWORDS = (
|
||||
"市场分析",
|
||||
"市场数据分析",
|
||||
"行业分析",
|
||||
"类目分析",
|
||||
"竞品分析",
|
||||
"趋势分析",
|
||||
)
|
||||
|
||||
SALES_HINT_KEYWORDS = (
|
||||
"销售数据",
|
||||
"销售报表",
|
||||
"销售看板",
|
||||
"公司销售",
|
||||
"业绩报表",
|
||||
"业绩数据",
|
||||
"费比监控",
|
||||
)
|
||||
|
||||
SENSITIVE_SALES_KEYWORDS = (
|
||||
"销售",
|
||||
"业绩",
|
||||
"售后",
|
||||
"退款",
|
||||
"店铺",
|
||||
"发货",
|
||||
"工单",
|
||||
"费比",
|
||||
)
|
||||
|
||||
EXPLICIT_AMOUNT_KEYWORDS = (
|
||||
"金额",
|
||||
"费用",
|
||||
"收入",
|
||||
"利润",
|
||||
"成本",
|
||||
"客单价",
|
||||
"gmv",
|
||||
"销售额",
|
||||
"退款额",
|
||||
"毛利",
|
||||
)
|
||||
|
||||
|
||||
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
|
||||
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
|
||||
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
|
||||
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
|
||||
|
||||
|
||||
|
||||
def bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class OpenClawDingTalkBot(ChatbotHandler):
|
||||
def __init__(self, logger: logging.Logger):
|
||||
super().__init__()
|
||||
self.logger = logger
|
||||
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
|
||||
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
|
||||
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
|
||||
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
|
||||
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
|
||||
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
|
||||
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
|
||||
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
|
||||
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
|
||||
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
|
||||
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
|
||||
self.unmask_url_hosts = {
|
||||
h.strip().lower()
|
||||
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
|
||||
if h.strip()
|
||||
}
|
||||
self._recent_event_ts = {}
|
||||
self.processing_hint_text = os.getenv(
|
||||
"PROCESSING_HINT_TEXT",
|
||||
"已收到,正在处理你的需求,请稍等1-3分钟。",
|
||||
).strip()
|
||||
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
|
||||
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
|
||||
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
|
||||
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
|
||||
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
|
||||
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
|
||||
|
||||
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||
if text.startswith(self.prefix):
|
||||
return True
|
||||
if msg.is_in_at_list:
|
||||
return True
|
||||
return not self.require_prefix
|
||||
|
||||
def _normalize_prompt(self, text: str) -> str:
|
||||
text = text.strip()
|
||||
if self.prefix and text.startswith(self.prefix):
|
||||
text = text[len(self.prefix):].strip()
|
||||
text = re.sub(r"^@\S+\s*", "", text).strip()
|
||||
return text
|
||||
|
||||
def _session_id(self, msg: ChatbotMessage) -> str:
|
||||
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
|
||||
base_session = f"{self.session_prefix}-{conversation_id}"
|
||||
|
||||
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
|
||||
if self.session_max_lines > 0 and os.path.exists(session_file):
|
||||
try:
|
||||
with open(session_file, "rb") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
if line_count >= self.session_max_lines:
|
||||
rotate_suffix = time.strftime("%Y%m%d%H%M")
|
||||
rotated = f"{base_session}-{rotate_suffix}"
|
||||
self.logger.info(
|
||||
"session auto-rotated: base=%s lines=%s rotated=%s",
|
||||
base_session,
|
||||
line_count,
|
||||
rotated,
|
||||
)
|
||||
return rotated
|
||||
except Exception:
|
||||
self.logger.exception("failed to inspect session file: %s", session_file)
|
||||
|
||||
return base_session
|
||||
|
||||
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
|
||||
if getattr(msg, "message_id", None):
|
||||
return f"msg:{msg.message_id}"
|
||||
|
||||
data = callback.data if isinstance(callback.data, dict) else {}
|
||||
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return f"data:{key}:{value}"
|
||||
|
||||
headers = getattr(callback, "headers", {}) or {}
|
||||
for key in ("messageId", "eventId", "processQueryKey"):
|
||||
value = headers.get(key)
|
||||
if value:
|
||||
return f"hdr:{key}:{value}"
|
||||
|
||||
raw = "|".join(
|
||||
[
|
||||
str(getattr(msg, "conversation_id", "") or ""),
|
||||
str(getattr(msg, "sender_id", "") or ""),
|
||||
str(getattr(msg, "sender_staff_id", "") or ""),
|
||||
str(getattr(msg, "create_at", "") or ""),
|
||||
prompt[:200],
|
||||
]
|
||||
)
|
||||
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _seen_or_mark_event(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
expire_before = now - max(self.dedupe_ttl_seconds, 30)
|
||||
|
||||
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
|
||||
for stale_key in stale_keys:
|
||||
self._recent_event_ts.pop(stale_key, None)
|
||||
|
||||
if key in self._recent_event_ts:
|
||||
return True
|
||||
|
||||
self._recent_event_ts[key] = now
|
||||
return False
|
||||
|
||||
def _is_market_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt)
|
||||
|
||||
if any(k in normalized for k in SALES_HINT_KEYWORDS):
|
||||
return False
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return True
|
||||
|
||||
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
|
||||
return True
|
||||
|
||||
if "市场" in normalized and "分析" in normalized:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_sales_prompt(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
|
||||
|
||||
def _explicit_amount_requested(self, prompt: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", prompt).lower()
|
||||
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
|
||||
|
||||
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
|
||||
if not self._is_sales_prompt(prompt):
|
||||
return answer
|
||||
if self._explicit_amount_requested(prompt):
|
||||
return answer
|
||||
|
||||
filtered_lines = []
|
||||
removed = 0
|
||||
for line in answer.splitlines():
|
||||
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
|
||||
removed += 1
|
||||
continue
|
||||
filtered_lines.append(line)
|
||||
|
||||
cleaned = "\n".join(filtered_lines).strip()
|
||||
if removed > 0:
|
||||
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
|
||||
if cleaned:
|
||||
cleaned = f"{cleaned}\n\n{note}"
|
||||
else:
|
||||
cleaned = note
|
||||
|
||||
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
|
||||
|
||||
def _extract_market_subject(self, prompt: str) -> str:
|
||||
normalized = prompt.strip()
|
||||
|
||||
def clean_subject(raw: str) -> str:
|
||||
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
|
||||
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
|
||||
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
|
||||
return v.strip()
|
||||
|
||||
def pick(raw: str):
|
||||
v = clean_subject(raw)
|
||||
if len(v) < 2:
|
||||
return None
|
||||
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
|
||||
return None
|
||||
return v
|
||||
|
||||
if "电脑桌" in normalized:
|
||||
return "电脑桌"
|
||||
|
||||
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(
|
||||
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
|
||||
normalized,
|
||||
)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
|
||||
if m:
|
||||
v = pick(m.group(1))
|
||||
if v:
|
||||
return v
|
||||
|
||||
return "电脑桌"
|
||||
|
||||
def _sanitize_answer(self, answer: str) -> str:
|
||||
if not self.hide_source_urls:
|
||||
return answer
|
||||
|
||||
keep_map = {}
|
||||
|
||||
def _keep_or_mask_url(match):
|
||||
raw = match.group(0)
|
||||
try:
|
||||
host = (urllib.parse.urlparse(raw).hostname or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
|
||||
if host and host in self.unmask_url_hosts:
|
||||
token = f"__KEEP_URL_{len(keep_map)}__"
|
||||
keep_map[token] = raw
|
||||
return token
|
||||
|
||||
return "[内部数据源地址已脱敏]"
|
||||
|
||||
out = URL_RE.sub(_keep_or_mask_url, answer)
|
||||
out = IP_RE.sub("[内部地址已脱敏]", out)
|
||||
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
|
||||
|
||||
# 保底脱敏(防止某些格式未被正则覆盖)
|
||||
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
|
||||
out = out.replace(token, "[内部数据源已脱敏]")
|
||||
|
||||
for token, raw in keep_map.items():
|
||||
out = out.replace(token, raw)
|
||||
|
||||
return out
|
||||
|
||||
def _run_market_brief(self, subject: str) -> str:
|
||||
cmd = [self.market_brief_bin, subject]
|
||||
self.logger.info("market-brief call: subject=%s", subject)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.market_brief_timeout,
|
||||
)
|
||||
output = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or output or "market brief error").strip()
|
||||
return f"市场分析执行失败: {err[:500]}"
|
||||
if not output:
|
||||
return "市场分析执行完成,但未返回文本结果。"
|
||||
return output
|
||||
|
||||
def _run_openclaw(self, session_id: str, prompt: str) -> str:
|
||||
cmd = [
|
||||
self.openclaw_bin,
|
||||
"agent",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--message",
|
||||
prompt,
|
||||
"--json",
|
||||
"--timeout",
|
||||
str(self.openclaw_timeout),
|
||||
]
|
||||
self.logger.info("openclaw call: session=%s", session_id)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.openclaw_timeout + 20,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
|
||||
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "openclaw error").strip()
|
||||
return f"OpenClaw 调用失败: {err[:500]}"
|
||||
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
payloads = data.get("result", {}).get("payloads", [])
|
||||
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
|
||||
answer = "\n".join(t for t in text_items if t).strip()
|
||||
if not answer:
|
||||
answer = "任务已执行,但未返回文本结果。"
|
||||
|
||||
if self._is_market_prompt(prompt) and (
|
||||
"无法直接从页面抓取" in answer
|
||||
or "需要登录后的操作" in answer
|
||||
or "工具版本不支持点击交互" in answer
|
||||
):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
fallback = self._run_market_brief(subject)
|
||||
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
|
||||
|
||||
return answer
|
||||
except Exception as exc:
|
||||
self.logger.exception("parse openclaw output failed")
|
||||
raw = (proc.stdout or "").strip()
|
||||
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
||||
|
||||
def _is_image_url(self, raw_url: str) -> bool:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(raw_url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = (parsed.path or "").lower()
|
||||
if IMAGE_EXT_RE.search(path):
|
||||
return True
|
||||
if host.startswith("img.") or "image" in host:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
|
||||
urls = []
|
||||
seen = set()
|
||||
for match in URL_RE.finditer(text or ""):
|
||||
raw = match.group(0).rstrip(".,;!?")
|
||||
if not self._is_image_url(raw):
|
||||
continue
|
||||
if raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
urls.append(raw)
|
||||
if len(urls) >= limit:
|
||||
break
|
||||
return urls
|
||||
|
||||
def _remove_image_urls(self, text: str, urls: list) -> str:
|
||||
out = text
|
||||
for u in urls:
|
||||
out = out.replace(u, "")
|
||||
out = re.sub(r"\n{3,}", "\n\n", out)
|
||||
return out.strip()
|
||||
|
||||
def _build_action_buttons(self) -> list:
|
||||
if not self.action_card_btn_url:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"text": self.action_card_btn_text or "打开链接",
|
||||
"url": self.action_card_btn_url,
|
||||
"iosUrl": self.action_card_btn_url,
|
||||
"color": "gray",
|
||||
}
|
||||
]
|
||||
|
||||
def _is_card_reply_ok(self, reply_obj) -> bool:
|
||||
if reply_obj is None:
|
||||
return False
|
||||
card_instance_id = getattr(reply_obj, "card_instance_id", None)
|
||||
if isinstance(card_instance_id, str):
|
||||
return bool(card_instance_id.strip())
|
||||
return bool(card_instance_id)
|
||||
|
||||
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool:
|
||||
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
|
||||
if prefer_card and self.reply_style == "action_card":
|
||||
image_urls = self._extract_image_urls(payload, limit=5)
|
||||
if image_urls:
|
||||
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
|
||||
markdown = self._remove_image_urls(payload, image_urls)
|
||||
if not markdown:
|
||||
markdown = "已整理相关图片如下:"
|
||||
try:
|
||||
card = self.reply_carousel_card(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
image_slider=slider,
|
||||
button_text=self.action_card_btn_text or "查看",
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
if self._is_card_reply_ok(card):
|
||||
return True
|
||||
self.logger.warning("carousel card reply returned empty id, fallback to action card/text")
|
||||
except Exception:
|
||||
self.logger.exception("carousel card reply failed, fallback to action card")
|
||||
|
||||
markdown = payload
|
||||
if self.action_card_title:
|
||||
markdown = f"### {self.action_card_title}\n\n{payload}"
|
||||
try:
|
||||
card = self.reply_markdown_button(
|
||||
incoming_message=msg,
|
||||
markdown=markdown,
|
||||
button_list=self._build_action_buttons(),
|
||||
tips=self.action_card_tips,
|
||||
title=self.action_card_title,
|
||||
logo=self.action_card_logo,
|
||||
)
|
||||
if self._is_card_reply_ok(card):
|
||||
return True
|
||||
self.logger.warning("action card reply returned empty id, fallback to text")
|
||||
except Exception:
|
||||
self.logger.exception("action card reply failed, fallback to text")
|
||||
resp = self.reply_text(payload, msg)
|
||||
if resp is None:
|
||||
self.logger.error("text reply failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def process(self, callback: CallbackMessage):
|
||||
try:
|
||||
msg = ChatbotMessage.from_dict(callback.data)
|
||||
except Exception:
|
||||
self.logger.exception("invalid callback payload")
|
||||
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
|
||||
|
||||
text = ""
|
||||
if msg.text and getattr(msg.text, "content", None):
|
||||
text = msg.text.content.strip()
|
||||
else:
|
||||
text_list = msg.get_text_list() or []
|
||||
text = text_list[0].strip() if text_list else ""
|
||||
|
||||
if not text:
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
if not self._should_handle(msg, text):
|
||||
return AckMessage.STATUS_OK, "ignored"
|
||||
|
||||
prompt = self._normalize_prompt(text)
|
||||
if not prompt:
|
||||
self._reply_message(
|
||||
msg,
|
||||
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
|
||||
prefer_card=False,
|
||||
)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
event_key = self._event_key(callback, msg, prompt)
|
||||
if self._seen_or_mark_event(event_key):
|
||||
self.logger.info(
|
||||
"duplicate callback ignored: key=%s conversation=%s sender=%s",
|
||||
event_key,
|
||||
getattr(msg, "conversation_id", ""),
|
||||
getattr(msg, "sender_id", ""),
|
||||
)
|
||||
return AckMessage.STATUS_OK, "duplicate_ignored"
|
||||
|
||||
if self.send_processing_hint:
|
||||
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
|
||||
try:
|
||||
self.reply_text(hint, msg)
|
||||
except Exception:
|
||||
self.logger.exception("failed to send processing hint")
|
||||
|
||||
session_id = self._session_id(msg)
|
||||
if self._is_market_prompt(prompt):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
answer = self._run_market_brief(subject)
|
||||
else:
|
||||
answer = self._run_openclaw(session_id, prompt)
|
||||
|
||||
answer = self._sanitize_answer(answer)
|
||||
answer = self._mask_sensitive_amounts(prompt, answer)
|
||||
if len(answer) > self.reply_max_chars:
|
||||
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
|
||||
|
||||
try:
|
||||
sent = self._reply_message(msg, answer, prefer_card=True)
|
||||
if sent:
|
||||
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
|
||||
else:
|
||||
self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer))
|
||||
except Exception:
|
||||
self.logger.exception("failed to send final reply: session=%s", session_id)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
|
||||
|
||||
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
|
||||
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
|
||||
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
|
||||
|
||||
credential = Credential(client_id, client_secret)
|
||||
client = DingTalkStreamClient(credential, logger=logger)
|
||||
bot = OpenClawDingTalkBot(logger)
|
||||
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
|
||||
if bool_env("REGISTER_DELEGATE_TOPIC", False):
|
||||
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
|
||||
logger.info("delegate topic enabled")
|
||||
else:
|
||||
logger.info("delegate topic disabled (default)")
|
||||
return client
|
||||
|
||||
|
||||
def self_check(logger: logging.Logger) -> int:
|
||||
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
|
||||
missing = [k for k in required if not os.getenv(k)]
|
||||
if missing:
|
||||
logger.error("missing env: %s", ", ".join(missing))
|
||||
return 2
|
||||
|
||||
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
|
||||
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
|
||||
return 3
|
||||
|
||||
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
|
||||
if not os.path.exists(market_brief_bin):
|
||||
logger.error("market brief bin not found: %s", market_brief_bin)
|
||||
return 4
|
||||
|
||||
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
|
||||
parser.add_argument("--check", action="store_true", help="run checks and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("openclaw-dingtalk-bridge")
|
||||
|
||||
if args.check:
|
||||
return self_check(logger)
|
||||
|
||||
client = build_client(logger)
|
||||
logger.info("bridge started, listening DingTalk stream")
|
||||
client.start_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
193
remote_sync/bridge_journal_today.log
Normal file
193
remote_sync/bridge_journal_today.log
Normal file
@ -0,0 +1,193 @@
|
||||
2026-02-09T20:17:22+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,915 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '49b2dabf-05b1-11f1-bebd-26ca3a8714ef'}
|
||||
2026-02-09T20:23:38+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:23:38,701 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:24:53+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:24:53,306 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:27:26+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:26,274 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,283 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,453 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b7654b04-05b2-11f1-b05d-329bedae46d2'}
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.584s CPU time.
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,456 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '090e3062-05b3-11f1-b7a2-faf2d53e39a8'}
|
||||
2026-02-09T20:30:12+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:30:12,030 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:31:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:31:53,570 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 10.324s CPU time.
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,412 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7fa31af1-05b3-11f1-89fb-2eaa794b294e'}
|
||||
2026-02-09T20:36:22+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:36:22,895 INFO openclaw-dingtalk-bridge: market-brief call: subject=继续分析价格段布局和需求
|
||||
2026-02-09T20:39:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:39:18,580 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:40:29+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:40:29,129 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:41:20+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:41:20,866 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,065 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,235 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca5546e8-05b4-11f1-8bff-5215a8ef9827'}
|
||||
2026-02-09T21:26:25+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:26:25,497 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T21:28:57+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:28:57,028 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T21:29:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:18,153 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,163 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,309 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '5bd4cb3b-05bb-11f1-80c0-7e88154b8801'}
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 1min 11.189s CPU time, 424.5M memory peak, 0B memory swap peak.
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T21:34:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:42,109 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '16df03a3-05bc-11f1-bebd-26ca3a8714ef'}
|
||||
2026-02-09T21:38:33+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:38:33,276 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T21:40:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:40:42,339 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T21:41:51+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:41:51,118 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
|
||||
2026-02-09T21:43:54+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:43:54,848 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T21:46:45+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:46:45,649 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
|
||||
2026-02-09T21:47:28+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:28,052 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T21:47:37+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:37,331 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,340 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,475 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'eafc88cb-05bd-11f1-b05d-329bedae46d2'}
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 29.883s CPU time.
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,930 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,931 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T21:55:56+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:56,063 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '0e354371-05bf-11f1-8bc5-6647947ab531'}
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,878 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,879 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T21:58:47+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:47,100 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '74253344-05bf-11f1-8bff-5215a8ef9827'}
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,415 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bd0e66b6-05bf-11f1-bee8-0eb3b764c0bb'}
|
||||
2026-02-09T22:02:29+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:02:29,164 INFO openclaw-dingtalk-bridge: market-brief call: subject=书架
|
||||
2026-02-09T22:05:13+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:05:13,915 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T22:12:39+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:12:39,286 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T22:16:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:49,128 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,138 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,278 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ff24b313-05c1-11f1-80c0-7e88154b8801'}
|
||||
2026-02-09T22:52:43+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:52:43,125 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T22:54:53+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:54:53,519 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T22:55:20+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:55:20,159 INFO openclaw-dingtalk-bridge: market-brief call: subject=基于以上
|
||||
2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T22:56:14+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:14,928 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T22:56:15+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:15,069 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7b4d9192-05c7-11f1-8d8a-de809d1c6511'}
|
||||
2026-02-09T22:58:08+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:58:08,695 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
|
||||
2026-02-09T23:01:22+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:01:22,116 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:05:11+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:05:11,545 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T23:06:21+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:21,414 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T23:06:42+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:42,222 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,233 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,374 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'f72b29ad-05c8-11f1-884b-5a20030ba585'}
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 2min 24.875s CPU time, 430.4M memory peak, 0B memory swap peak.
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,825 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,988 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '2a32a932-05c9-11f1-884b-5a20030ba585'}
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,914 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '3e6bc465-05c9-11f1-8d8a-de809d1c6511'}
|
||||
2026-02-09T23:12:47+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:12:47,364 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:17:27+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:27,896 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgh7C3eoXlIiu8R6WVGDYXqA== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
|
||||
2026-02-09T23:17:28+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:28,064 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:17:37+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:37,266 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgmmZbADzAr1YhyOavIO6amw== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
|
||||
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.544s CPU time, 414.7M memory peak, 0B memory swap peak.
|
||||
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,140 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,274 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b45b2986-05ca-11f1-bebd-26ca3a8714ef'}
|
||||
2026-02-09T23:21:25+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:21:25,894 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:24:56+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:24:56,356 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
|
||||
2026-02-09T23:26:21+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:26:21,744 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgc8yPTi4RlHrE+NDnT673hw== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
|
||||
2026-02-09T23:31:51+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:31:51,202 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:38:36+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:36,958 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
|
||||
2026-02-09T23:38:59+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:59,471 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
|
||||
2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,473 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,609 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '79d969eb-05cd-11f1-8d8a-de809d1c6511'}
|
||||
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 19.556s CPU time, 433.0M memory peak, 0B memory swap peak.
|
||||
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,214 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ba9578cf-05cd-11f1-8bc5-6647947ab531'}
|
||||
2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342
|
||||
2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342
|
||||
2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342 chars=938
|
||||
2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgK5zYDsuFOjCcFfmwOMWvXQ== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 4.965s CPU time, 2.5M memory peak, 0B memory swap peak.
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,506 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'd24bc157-05ce-11f1-80c0-7e88154b8801'}
|
||||
2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041
|
||||
2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041
|
||||
2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,519 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041 chars=1197
|
||||
2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,520 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgviGW4x1TkcCrGfqVg4kyHA== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
|
||||
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.107s CPU time.
|
||||
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,227 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca672ec1-05d6-11f1-80c0-7e88154b8801'}
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,914 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-10T00:52:37+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:37,075 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bce5bf3b-05d7-11f1-8bc5-6647947ab531'}
|
||||
2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053
|
||||
2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053
|
||||
2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,789 ERROR dingtalk_stream.card_replier: CardReplier.create_and_send_card failed, create card instance failed, error=403 Client Error: Forbidden for url: https://api.dingtalk.com/v1.0/card/instances, response.text={"code":"Forbidden.AccessDenied.AccessTokenPermissionDenied","requestid":"25E39354-5AEE-70E0-BEB3-AB85E5BCC784","message":"应用尚未开通所需的权限:[Card.Instance.Write],点击链接申请并开通即可:https://open-dev.dingtalk.com/appscope/apply?content=ding3oaumxwg7sol5stc%23Card.Instance.Write","accessdenieddetail":{"requiredScopes":["Card.Instance.Write"]}}
|
||||
2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,790 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053 chars=847
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.037s CPU time.
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
|
||||
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,367 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b6382dab-05d8-11f1-bebd-26ca3a8714ef'}
|
||||
17
remote_sync/company-db
Normal file
17
remote_sync/company-db
Normal file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VENV="/root/.openclaw/.venv-db"
|
||||
TOOL="/root/.openclaw/workspace/sqlserver/company_db_tool.py"
|
||||
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
echo "missing python runtime: $VENV/bin/python" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$TOOL" ]; then
|
||||
echo "missing tool script: $TOOL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$VENV/bin/python" "$TOOL" "$@"
|
||||
378
remote_sync/company_db_tool.py
Normal file
378
remote_sync/company_db_tool.py
Normal file
@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import pytds
|
||||
|
||||
BUSINESS_TABLES = [
|
||||
"jst_goods_profit_kpi_items_afs",
|
||||
"jst_sku",
|
||||
"fys_shopInfo",
|
||||
"jst_order_express_details",
|
||||
"fys_SkuDataLibrary_SpuConfig",
|
||||
"op_operManage",
|
||||
"op_TargetManagement",
|
||||
"fys_Ticketing_install",
|
||||
"fys_Ticketing_install_item",
|
||||
"fys_jstFreightCost",
|
||||
"fys_jstFreightCostDetail",
|
||||
"pc_kfChatLog",
|
||||
]
|
||||
|
||||
ENV_FILE = "/root/.openclaw/credentials/company-sql.env"
|
||||
|
||||
|
||||
def load_env_file(path: str) -> None:
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
return
|
||||
for raw in p.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
def conn_params() -> Dict[str, object]:
|
||||
load_env_file(ENV_FILE)
|
||||
return {
|
||||
"server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"),
|
||||
"port": int(os.environ.get("COMPANY_DB_PORT", "1433")),
|
||||
"database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"),
|
||||
"user": os.environ.get("COMPANY_DB_USER", "nanting"),
|
||||
"password": os.environ.get("COMPANY_DB_PASSWORD", ""),
|
||||
"login_timeout": 15,
|
||||
"timeout": 300,
|
||||
}
|
||||
|
||||
|
||||
def get_conn():
|
||||
params = conn_params()
|
||||
if not params["password"]:
|
||||
raise RuntimeError("COMPANY_DB_PASSWORD is empty")
|
||||
return pytds.connect(**params)
|
||||
|
||||
|
||||
def _fmt(value):
|
||||
if isinstance(value, dt.datetime):
|
||||
return value.isoformat(sep=" ")
|
||||
if isinstance(value, (dt.date, dt.time)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def print_rows(columns: List[str], rows: List[Tuple[object, ...]], truncated: bool = False) -> None:
|
||||
print("COLUMNS:", ", ".join(columns))
|
||||
if not rows:
|
||||
print("ROWS: 0")
|
||||
return
|
||||
if truncated:
|
||||
print(f"ROWS_SHOWN: {len(rows)} (truncated)")
|
||||
else:
|
||||
print(f"ROWS: {len(rows)}")
|
||||
for row in rows:
|
||||
obj = {columns[i]: _fmt(row[i]) for i in range(len(columns))}
|
||||
print(json.dumps(obj, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def split_go_batches(sql_text: str) -> List[str]:
|
||||
batches: List[str] = []
|
||||
current: List[str] = []
|
||||
for line in sql_text.splitlines():
|
||||
if line.strip().upper() == "GO":
|
||||
block = "\n".join(current).strip()
|
||||
if block:
|
||||
batches.append(block)
|
||||
current = []
|
||||
else:
|
||||
current.append(line)
|
||||
block = "\n".join(current).strip()
|
||||
if block:
|
||||
batches.append(block)
|
||||
return batches
|
||||
|
||||
|
||||
def fetch_with_limit(cursor, row_limit: int) -> Tuple[List[Tuple[object, ...]], bool]:
|
||||
if row_limit <= 0:
|
||||
return cursor.fetchall(), False
|
||||
|
||||
rows: List[Tuple[object, ...]] = []
|
||||
truncated = False
|
||||
while len(rows) <= row_limit:
|
||||
batch = cursor.fetchmany(256)
|
||||
if not batch:
|
||||
break
|
||||
rows.extend(batch)
|
||||
if len(rows) > row_limit:
|
||||
rows = rows[:row_limit]
|
||||
truncated = True
|
||||
break
|
||||
return rows, truncated
|
||||
|
||||
|
||||
def run_sql(sql_text: str, row_limit: int) -> int:
|
||||
batches = split_go_batches(sql_text)
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
set_index = 0
|
||||
for batch in batches:
|
||||
cur.execute(batch)
|
||||
while True:
|
||||
if cur.description:
|
||||
set_index += 1
|
||||
cols = [c[0] for c in cur.description]
|
||||
rows, truncated = fetch_with_limit(cur, row_limit)
|
||||
print(f"RESULT_SET {set_index}")
|
||||
print_rows(cols, rows, truncated=truncated)
|
||||
if not cur.nextset():
|
||||
break
|
||||
cur.close()
|
||||
return set_index
|
||||
|
||||
|
||||
def cmd_health(_args: argparse.Namespace) -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT @@VERSION AS version, DB_NAME() AS db_name, GETDATE() AS server_time")
|
||||
cols = [c[0] for c in cur.description]
|
||||
row = cur.fetchone()
|
||||
data = {cols[i]: _fmt(row[i]) for i in range(len(cols))}
|
||||
print(json.dumps(data, ensure_ascii=False, default=str, indent=2))
|
||||
cur.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tables(_args: argparse.Namespace) -> int:
|
||||
names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES])
|
||||
sql = f"""
|
||||
SELECT c.TABLE_NAME, COUNT(*) AS column_count
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||
WHERE c.TABLE_SCHEMA='dbo'
|
||||
AND c.TABLE_NAME IN ({names})
|
||||
GROUP BY c.TABLE_NAME
|
||||
ORDER BY c.TABLE_NAME
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
cols = [c[0] for c in cur.description]
|
||||
print_rows(cols, rows)
|
||||
cur.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_columns(args: argparse.Namespace) -> int:
|
||||
table = args.table
|
||||
sql = f"""
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
NUMERIC_PRECISION,
|
||||
NUMERIC_SCALE,
|
||||
IS_NULLABLE,
|
||||
ORDINAL_POSITION
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA='dbo' AND TABLE_NAME='{table}'
|
||||
ORDER BY ORDINAL_POSITION
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
cols = [c[0] for c in cur.description]
|
||||
print_rows(cols, rows)
|
||||
cur.close()
|
||||
return 0
|
||||
|
||||
|
||||
def _collect_schema() -> Dict[str, List[Tuple[object, ...]]]:
|
||||
names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES])
|
||||
sql = f"""
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
NUMERIC_PRECISION,
|
||||
NUMERIC_SCALE,
|
||||
IS_NULLABLE,
|
||||
ORDINAL_POSITION
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA='dbo'
|
||||
AND TABLE_NAME IN ({names})
|
||||
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
by_table: Dict[str, List[Tuple[object, ...]]] = {t: [] for t in BUSINESS_TABLES}
|
||||
for row in rows:
|
||||
by_table[str(row[0])].append(row)
|
||||
return by_table
|
||||
|
||||
|
||||
def cmd_catalog(args: argparse.Namespace) -> int:
|
||||
by_table = _collect_schema()
|
||||
now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
out = pathlib.Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("# 公司销售/售后数据库字段与维度目录")
|
||||
lines.append("")
|
||||
lines.append(f"> 自动生成时间: {now}")
|
||||
lines.append("> 数据源: SQLServer / JSDataMiddlePlatform / dbo")
|
||||
lines.append("> 适用范围: 公司销售数据、售后数据、店铺运营、物流运费、安装工单、客服问题")
|
||||
lines.append("")
|
||||
lines.append("## 一、强制路由规则(给AI)")
|
||||
lines.append("")
|
||||
lines.append("- 凡是涉及 `销售/业绩/售后/退款/费比/店铺业绩` 的需求,必须走数据库查询,禁止走旧业绩网页。")
|
||||
lines.append("- 先查字段目录再写 SQL:`/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`")
|
||||
lines.append("- 优先复用周报模板:`/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql`")
|
||||
lines.append("- 输出结论必须标注:`时间范围 + 使用表 + 关键字段 + 过滤条件`。")
|
||||
lines.append("")
|
||||
lines.append("## 二、可查询业务维度(语义层)")
|
||||
lines.append("")
|
||||
dimension_rows = [
|
||||
("订单维度", "订单号、子订单号、内部订单ID、订单状态、售后状态", "jst_order_express_details, jst_goods_profit_kpi_items_afs"),
|
||||
("商品维度", "SKU、SPU、货号、商品名称、规格、类目、品牌", "jst_sku, op_TargetManagement, op_operManage, fys_SkuDataLibrary_SpuConfig"),
|
||||
("店铺维度", "店铺名称、平台、店铺编码、负责人、渠道类型", "fys_shopInfo, jst_order_express_details, jst_goods_profit_kpi_items_afs"),
|
||||
("销售金额维度", "商品金额、付款金额、实发金额、成本、毛利相关费用", "jst_goods_profit_kpi_items_afs, jst_sku"),
|
||||
("售后维度", "售后类型、售后原因、退款/退货金额、售后标签", "jst_goods_profit_kpi_items_afs, pc_kfChatLog"),
|
||||
("物流维度", "快递公司、快递单号、物流状态、运费、重量、收货地区", "jst_order_express_details, fys_jstFreightCost, fys_jstFreightCostDetail"),
|
||||
("安装工单维度", "工单状态、安装费用、结算金额、服务商", "fys_Ticketing_install, fys_Ticketing_install_item"),
|
||||
("人员维度", "运营、设计师、采购、美工、店长、客服", "op_TargetManagement, op_operManage, fys_shopInfo, fys_SkuDataLibrary_SpuConfig"),
|
||||
("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
|
||||
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
|
||||
]
|
||||
lines.append("| 维度 | 可查询内容 | 主要来源表 |")
|
||||
lines.append("|---|---|---|")
|
||||
for row in dimension_rows:
|
||||
lines.append(f"| {row[0]} | {row[1]} | {row[2]} |")
|
||||
lines.append("")
|
||||
lines.append("## 三、核心关联键(Join Key)")
|
||||
lines.append("")
|
||||
lines.append("- `order_id`:`so_id / undefined2 / orderNo / OrderNum / Sourceno`")
|
||||
lines.append("- `inner_order_id`:`inner_order_id / undefined1 / o_id`")
|
||||
lines.append("- `sku_id`:`sku_id / undefined10 / barCode`")
|
||||
lines.append("- `spu`:`spu / other_1 / undefined96`")
|
||||
lines.append("- `shop_name`:`shop_name / shopName / undefined8 / shopname`")
|
||||
lines.append("")
|
||||
lines.append("## 四、全表字段清单(真实表头)")
|
||||
lines.append("")
|
||||
|
||||
total_cols = 0
|
||||
for table in BUSINESS_TABLES:
|
||||
rows = by_table.get(table, [])
|
||||
total_cols += len(rows)
|
||||
lines.append(f"### {table}({len(rows)} 列)")
|
||||
lines.append("")
|
||||
lines.append("| 序号 | 字段名 | 类型 | 长度/精度 | 可空 |")
|
||||
lines.append("|---:|---|---|---|---|")
|
||||
for r in rows:
|
||||
_, col, dtype, clen, prec, scale, nullable, ord_pos = r
|
||||
if clen is not None:
|
||||
length = str(clen)
|
||||
elif prec is not None:
|
||||
if scale is not None:
|
||||
length = f"{prec},{scale}"
|
||||
else:
|
||||
length = str(prec)
|
||||
else:
|
||||
length = "-"
|
||||
lines.append(f"| {ord_pos} | {col} | {dtype} | {length} | {nullable} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 五、统计")
|
||||
lines.append("")
|
||||
lines.append(f"- 业务表数量:{len(BUSINESS_TABLES)}")
|
||||
lines.append(f"- 字段总数:{total_cols}")
|
||||
lines.append("- 建议:高频分析优先在订单售后主表 `jst_goods_profit_kpi_items_afs` 先聚合,再按需关联其他表,避免超时。")
|
||||
|
||||
out.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(str(out))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_query(args: argparse.Namespace) -> int:
|
||||
sql_text = None
|
||||
|
||||
if args.file:
|
||||
sql_text = pathlib.Path(args.file).read_text(encoding="utf-8")
|
||||
elif args.sql:
|
||||
sql_text = args.sql
|
||||
elif args.sql_positional:
|
||||
sql_text = " ".join(args.sql_positional).strip()
|
||||
|
||||
if not sql_text:
|
||||
raise SystemExit("query requires --sql / --file / or inline SQL text")
|
||||
|
||||
set_count = run_sql(sql_text, args.limit)
|
||||
print(f"TOTAL_RESULT_SETS: {set_count}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="company-db", description="Company SQLServer helper")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("health")
|
||||
sub.add_parser("tables")
|
||||
|
||||
p_cols = sub.add_parser("columns")
|
||||
p_cols.add_argument("table")
|
||||
|
||||
p_query = sub.add_parser("query")
|
||||
p_query.add_argument("--sql", help="SQL text")
|
||||
p_query.add_argument("--file", help="SQL file path")
|
||||
p_query.add_argument("--limit", type=int, default=200, help="row limit via SET ROWCOUNT")
|
||||
p_query.add_argument("sql_positional", nargs="*", help="inline SQL text, e.g. company-db query SELECT 1")
|
||||
|
||||
p_run = sub.add_parser("run-file")
|
||||
p_run.add_argument("file", help="SQL file path")
|
||||
p_run.add_argument("--limit", type=int, default=0, help="max rows shown per result set (0 means no limit)")
|
||||
|
||||
p_cat = sub.add_parser("catalog")
|
||||
p_cat.add_argument("--out", default="/root/.openclaw/workspace/outputs/company-sales-db-catalog.md")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "health":
|
||||
return cmd_health(args)
|
||||
if args.cmd == "tables":
|
||||
return cmd_tables(args)
|
||||
if args.cmd == "columns":
|
||||
return cmd_columns(args)
|
||||
if args.cmd == "query":
|
||||
return cmd_query(args)
|
||||
if args.cmd == "run-file":
|
||||
args.sql = None
|
||||
args.file = args.file
|
||||
return cmd_query(args)
|
||||
if args.cmd == "catalog":
|
||||
return cmd_catalog(args)
|
||||
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1344
remote_sync/customer-service-analysis
Executable file
1344
remote_sync/customer-service-analysis
Executable file
File diff suppressed because it is too large
Load Diff
274
remote_sync/customer_service_experience_report.sql
Normal file
274
remote_sync/customer_service_experience_report.sql
Normal file
@ -0,0 +1,274 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @start_date date = DATEADD(day, -29, CAST(GETDATE() AS date));
|
||||
DECLARE @end_date date = CAST(GETDATE() AS date);
|
||||
DECLARE @start_time datetime2 = CAST(@start_date AS datetime2);
|
||||
DECLARE @end_time datetime2 = DATEADD(day, 1, CAST(@end_date AS datetime2));
|
||||
DECLARE @shop nvarchar(100) = N''; -- 可填店铺关键词,例如 N'亿家达'
|
||||
DECLARE @limit int = 20;
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_base') IS NOT NULL
|
||||
DROP TABLE #chat_base;
|
||||
|
||||
SELECT
|
||||
c.id,
|
||||
NULLIF(c.orderNo, '') AS order_no,
|
||||
ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name,
|
||||
c.createdAt,
|
||||
c.updatedAt,
|
||||
c.state,
|
||||
ISNULL(NULLIF(LTRIM(RTRIM(c.mainReason)), ''), N'未标注') AS main_reason,
|
||||
ISNULL(NULLIF(LTRIM(RTRIM(c.secondReason)), ''), N'未标注') AS second_reason,
|
||||
ISNULL(NULLIF(LTRIM(RTRIM(c.thirdReason)), ''), N'未标注') AS third_reason,
|
||||
CAST(c.content AS nvarchar(max)) AS content_text,
|
||||
CAST(c.reasonContent AS nvarchar(max)) AS reason_text
|
||||
INTO #chat_base
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
WHERE c.createdAt >= @start_time
|
||||
AND c.createdAt < @end_time
|
||||
AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%');
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_norm') IS NOT NULL
|
||||
DROP TABLE #chat_norm;
|
||||
|
||||
SELECT
|
||||
b.*,
|
||||
CASE WHEN b.content_text IS NULL OR LTRIM(RTRIM(b.content_text)) IN (N'', N'[]') THEN 1 ELSE 0 END AS is_empty_content,
|
||||
CASE WHEN b.main_reason = N'未标注' OR b.second_reason = N'未标注' THEN 1 ELSE 0 END AS is_missing_reason,
|
||||
CASE WHEN b.main_reason = N'无对应分类' OR b.second_reason = N'无对应分类' THEN 1 ELSE 0 END AS is_no_match_reason,
|
||||
CASE WHEN b.content_text LIKE N'%欢迎%' OR b.content_text LIKE N'%您好%' OR b.content_text LIKE N'%亲,%' OR b.content_text LIKE N'%亲,%' THEN 1 ELSE 0 END AS has_greeting,
|
||||
CASE WHEN b.content_text LIKE N'%抱歉%' OR b.content_text LIKE N'%不好意思%' OR b.content_text LIKE N'%理解您%' THEN 1 ELSE 0 END AS has_empathy,
|
||||
CASE WHEN b.content_text LIKE N'%马上%' OR b.content_text LIKE N'%尽快%' OR b.content_text LIKE N'%给您处理%' OR b.content_text LIKE N'%为您处理%' THEN 1 ELSE 0 END AS has_commitment,
|
||||
CASE WHEN b.content_text LIKE N'%感谢%' OR b.content_text LIKE N'%祝您%' OR b.content_text LIKE N'%辛苦%' THEN 1 ELSE 0 END AS has_closing,
|
||||
CASE WHEN b.content_text LIKE N'%不知道%' OR b.content_text LIKE N'%没办法%' OR b.content_text LIKE N'%无法处理%' OR b.content_text LIKE N'%不清楚%' OR b.content_text LIKE N'%等不了%' OR b.content_text LIKE N'%太慢%' THEN 1 ELSE 0 END AS has_risk_phrase
|
||||
INTO #chat_norm
|
||||
FROM #chat_base AS b;
|
||||
|
||||
-- 1) 时间段客服体验总览
|
||||
SELECT
|
||||
@start_date AS start_date,
|
||||
@end_date AS end_date,
|
||||
COUNT(*) AS session_count,
|
||||
COUNT(DISTINCT order_no) AS order_count,
|
||||
COUNT(DISTINCT shop_name) AS shop_count,
|
||||
SUM(is_empty_content) AS empty_content_count,
|
||||
CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct,
|
||||
SUM(is_missing_reason) AS missing_reason_count,
|
||||
CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct,
|
||||
SUM(is_no_match_reason) AS no_match_reason_count,
|
||||
CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
|
||||
SUM(CASE WHEN state = 1 THEN 1 ELSE 0 END) AS state_1_count,
|
||||
SUM(CASE WHEN state = 2 THEN 1 ELSE 0 END) AS state_2_count
|
||||
FROM #chat_norm;
|
||||
|
||||
-- 2) 用户反馈问题结构 TOP(一级/二级)
|
||||
SELECT TOP (@limit)
|
||||
main_reason,
|
||||
second_reason,
|
||||
COUNT(*) AS session_count,
|
||||
COUNT(DISTINCT order_no) AS order_count,
|
||||
CAST(100.0 * COUNT(*) / NULLIF((SELECT COUNT(*) FROM #chat_norm), 0) AS decimal(6,2)) AS session_share_pct
|
||||
FROM #chat_norm
|
||||
GROUP BY main_reason, second_reason
|
||||
ORDER BY session_count DESC, order_count DESC;
|
||||
|
||||
-- 3) 客服体验店铺对比(体验分)
|
||||
WITH shop_agg AS (
|
||||
SELECT
|
||||
shop_name,
|
||||
COUNT(*) AS session_count,
|
||||
SUM(is_empty_content) AS empty_content_count,
|
||||
SUM(is_missing_reason) AS missing_reason_count,
|
||||
SUM(is_no_match_reason) AS no_match_reason_count
|
||||
FROM #chat_norm
|
||||
GROUP BY shop_name
|
||||
)
|
||||
SELECT TOP (@limit)
|
||||
s.shop_name,
|
||||
s.session_count,
|
||||
CAST(100.0 * s.empty_content_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS empty_content_rate_pct,
|
||||
CAST(100.0 * s.missing_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS missing_reason_rate_pct,
|
||||
CAST(100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
|
||||
CASE
|
||||
WHEN (
|
||||
100.0
|
||||
- (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4
|
||||
- (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4
|
||||
- (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2
|
||||
) < 0 THEN CAST(0 AS decimal(6,2))
|
||||
ELSE CAST(
|
||||
100.0
|
||||
- (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4
|
||||
- (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4
|
||||
- (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2
|
||||
AS decimal(6,2)
|
||||
)
|
||||
END AS experience_score
|
||||
FROM shop_agg AS s
|
||||
ORDER BY experience_score DESC, s.session_count DESC;
|
||||
|
||||
-- 4) 话术覆盖分析
|
||||
SELECT
|
||||
COUNT(*) AS session_count,
|
||||
SUM(has_greeting) AS greeting_hit_count,
|
||||
CAST(100.0 * SUM(has_greeting) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS greeting_hit_rate_pct,
|
||||
SUM(has_empathy) AS empathy_hit_count,
|
||||
CAST(100.0 * SUM(has_empathy) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empathy_hit_rate_pct,
|
||||
SUM(has_commitment) AS commitment_hit_count,
|
||||
CAST(100.0 * SUM(has_commitment) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS commitment_hit_rate_pct,
|
||||
SUM(has_closing) AS closing_hit_count,
|
||||
CAST(100.0 * SUM(has_closing) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS closing_hit_rate_pct,
|
||||
SUM(has_risk_phrase) AS risk_phrase_hit_count,
|
||||
CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct
|
||||
FROM #chat_norm;
|
||||
|
||||
-- 5) 风险话术命中 TOP
|
||||
WITH phrase_hits AS (
|
||||
SELECT
|
||||
n.id,
|
||||
n.order_no,
|
||||
n.shop_name,
|
||||
v.risk_phrase
|
||||
FROM #chat_norm AS n
|
||||
CROSS APPLY (VALUES
|
||||
(CASE WHEN n.content_text LIKE N'%不知道%' THEN N'不知道' END),
|
||||
(CASE WHEN n.content_text LIKE N'%没办法%' THEN N'没办法' END),
|
||||
(CASE WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理' END),
|
||||
(CASE WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚' END),
|
||||
(CASE WHEN n.content_text LIKE N'%太慢%' THEN N'太慢' END),
|
||||
(CASE WHEN n.content_text LIKE N'%等不了%' THEN N'等不了' END)
|
||||
) AS v(risk_phrase)
|
||||
WHERE v.risk_phrase IS NOT NULL
|
||||
)
|
||||
SELECT TOP (@limit)
|
||||
risk_phrase,
|
||||
COUNT(*) AS hit_count,
|
||||
COUNT(DISTINCT id) AS session_count,
|
||||
COUNT(DISTINCT order_no) AS order_count
|
||||
FROM phrase_hits
|
||||
GROUP BY risk_phrase
|
||||
ORDER BY session_count DESC, hit_count DESC;
|
||||
|
||||
-- 6) 每日体验趋势
|
||||
SELECT
|
||||
CONVERT(date, createdAt) AS stat_date,
|
||||
COUNT(*) AS session_count,
|
||||
CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
|
||||
CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct,
|
||||
CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct,
|
||||
CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct
|
||||
FROM #chat_norm
|
||||
GROUP BY CONVERT(date, createdAt)
|
||||
ORDER BY stat_date ASC;
|
||||
|
||||
-- 7) 对话案例(风险话术)+ 推荐回复
|
||||
WITH case_pool AS (
|
||||
SELECT
|
||||
n.id,
|
||||
n.order_no,
|
||||
n.shop_name,
|
||||
n.main_reason,
|
||||
n.second_reason,
|
||||
n.content_text,
|
||||
CASE
|
||||
WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚'
|
||||
WHEN n.content_text LIKE N'%不知道%' THEN N'不知道'
|
||||
WHEN n.content_text LIKE N'%没办法%' THEN N'没办法'
|
||||
WHEN n.content_text LIKE N'%太慢%' THEN N'太慢'
|
||||
WHEN n.content_text LIKE N'%等不了%' THEN N'等不了'
|
||||
WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理'
|
||||
ELSE NULL
|
||||
END AS risk_tag
|
||||
FROM #chat_norm AS n
|
||||
)
|
||||
SELECT TOP (@limit)
|
||||
order_no,
|
||||
shop_name,
|
||||
main_reason,
|
||||
second_reason,
|
||||
risk_tag,
|
||||
LEFT(REPLACE(REPLACE(ISNULL(content_text, N''), CHAR(13), N' '), CHAR(10), N' '), 220) AS dialogue_sample,
|
||||
CASE
|
||||
WHEN risk_tag IN (N'不清楚', N'不知道')
|
||||
THEN N'建议回复:我先帮您核实一下该问题,预计5分钟内给您明确答复。'
|
||||
WHEN risk_tag = N'没办法'
|
||||
THEN N'建议回复:这个情况我来帮您申请处理方案,先给您两个可行选项并同步时效。'
|
||||
WHEN risk_tag = N'无法处理'
|
||||
THEN N'建议回复:我已升级给对应专员处理,预计今天内给您处理结果。'
|
||||
WHEN risk_tag IN (N'太慢', N'等不了')
|
||||
THEN N'建议回复:抱歉让您久等,我现在优先为您加急处理,并在10分钟内回报进展。'
|
||||
ELSE N'建议回复:收到您的问题,我先确认订单与处理路径,稍后给您明确方案。'
|
||||
END AS suggested_reply
|
||||
FROM case_pool
|
||||
WHERE risk_tag IS NOT NULL
|
||||
ORDER BY id DESC;
|
||||
|
||||
-- 8) 客服能力评分(参考天猫服务保障维度,按可用字段做代理映射)
|
||||
;WITH score_base AS (
|
||||
SELECT
|
||||
shop_name,
|
||||
COUNT(*) AS session_count,
|
||||
CAST(SUM(has_commitment) AS float) / NULLIF(COUNT(*), 0) AS commitment_rate,
|
||||
CAST(SUM(has_empathy) AS float) / NULLIF(COUNT(*), 0) AS empathy_rate,
|
||||
CAST(SUM(has_closing) AS float) / NULLIF(COUNT(*), 0) AS closing_rate,
|
||||
CAST(SUM(has_risk_phrase) AS float) / NULLIF(COUNT(*), 0) AS risk_phrase_rate,
|
||||
CAST(SUM(CASE WHEN second_reason LIKE N'%催退款%' OR second_reason LIKE N'%催取件%' THEN 1 ELSE 0 END) AS float) / NULLIF(COUNT(*), 0) AS refund_urge_rate,
|
||||
CAST(SUM(is_no_match_reason) AS float) / NULLIF(COUNT(*), 0) AS no_match_rate,
|
||||
CAST(SUM(is_missing_reason) AS float) / NULLIF(COUNT(*), 0) AS missing_reason_rate
|
||||
FROM #chat_norm
|
||||
GROUP BY shop_name
|
||||
),
|
||||
score_calc AS (
|
||||
SELECT
|
||||
sb.*,
|
||||
100.0 * (0.70 * sb.commitment_rate + 0.30 * (1.0 - sb.risk_phrase_rate)) AS resp_ability_score,
|
||||
100.0 * (0.45 * sb.empathy_rate + 0.35 * sb.closing_rate + 0.20 * (1.0 - sb.risk_phrase_rate)) AS satisfaction_score,
|
||||
100.0 * (1.0 - sb.refund_urge_rate) AS refund_efficiency_score,
|
||||
100.0 * (1.0 - (0.80 * sb.no_match_rate + 0.20 * sb.missing_reason_rate)) AS help_risk_control_score
|
||||
FROM score_base AS sb
|
||||
)
|
||||
SELECT TOP (@limit)
|
||||
shop_name,
|
||||
session_count,
|
||||
CAST(resp_ability_score AS decimal(6,2)) AS resp_ability_score,
|
||||
CAST(satisfaction_score AS decimal(6,2)) AS satisfaction_score,
|
||||
CAST(refund_efficiency_score AS decimal(6,2)) AS refund_efficiency_score,
|
||||
CAST(help_risk_control_score AS decimal(6,2)) AS help_risk_control_score,
|
||||
CAST(
|
||||
0.30 * resp_ability_score
|
||||
+ 0.30 * satisfaction_score
|
||||
+ 0.20 * refund_efficiency_score
|
||||
+ 0.20 * help_risk_control_score
|
||||
AS decimal(6,2)
|
||||
) AS service_capability_score,
|
||||
CASE
|
||||
WHEN (
|
||||
0.30 * resp_ability_score
|
||||
+ 0.30 * satisfaction_score
|
||||
+ 0.20 * refund_efficiency_score
|
||||
+ 0.20 * help_risk_control_score
|
||||
) >= 90 THEN N'S'
|
||||
WHEN (
|
||||
0.30 * resp_ability_score
|
||||
+ 0.30 * satisfaction_score
|
||||
+ 0.20 * refund_efficiency_score
|
||||
+ 0.20 * help_risk_control_score
|
||||
) >= 80 THEN N'A'
|
||||
WHEN (
|
||||
0.30 * resp_ability_score
|
||||
+ 0.30 * satisfaction_score
|
||||
+ 0.20 * refund_efficiency_score
|
||||
+ 0.20 * help_risk_control_score
|
||||
) >= 70 THEN N'B'
|
||||
WHEN (
|
||||
0.30 * resp_ability_score
|
||||
+ 0.30 * satisfaction_score
|
||||
+ 0.20 * refund_efficiency_score
|
||||
+ 0.20 * help_risk_control_score
|
||||
) >= 60 THEN N'C'
|
||||
ELSE N'D'
|
||||
END AS service_grade,
|
||||
N'映射说明: 响应能力≈旺旺响应能力; 满意度≈旺旺满意度; 退款效率≈退款处理体验; 求助风险控制≈平台求助风险(代理口径)' AS score_mapping_note
|
||||
FROM score_calc
|
||||
ORDER BY service_capability_score DESC, session_count DESC;
|
||||
133
remote_sync/dalongxia_analytics_client.js
Executable file
133
remote_sync/dalongxia_analytics_client.js
Executable file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// 用法:
|
||||
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js progress
|
||||
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js overview
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai";
|
||||
const APP_KEY = process.env.OPEN_API_APP_KEY;
|
||||
const APP_SECRET = process.env.OPEN_API_APP_SECRET;
|
||||
|
||||
if (!APP_KEY || !APP_SECRET) {
|
||||
console.error("缺少环境变量:OPEN_API_APP_KEY / OPEN_API_APP_SECRET");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function hmacSha256Hex(secret, payload) {
|
||||
return crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function nowTs() {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
async function postJson(path, body) {
|
||||
const resp = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (err) {
|
||||
throw new Error(`HTTP ${resp.status} 非JSON响应: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
if (!resp.ok || data.code !== 200) {
|
||||
throw new Error(`HTTP ${resp.status} / code=${data.code} / msg=${data.message}`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function queryTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) {
|
||||
const timestamp = nowTs();
|
||||
const payload = [
|
||||
"analytics_task_progress_overview",
|
||||
APP_KEY,
|
||||
dateRange,
|
||||
month || "",
|
||||
String(deptId || ""),
|
||||
timestamp,
|
||||
].join("|");
|
||||
const sign = hmacSha256Hex(APP_SECRET, payload);
|
||||
|
||||
return postJson("/api/open/analytics/task-progress/overview", {
|
||||
appKey: APP_KEY,
|
||||
timestamp,
|
||||
sign,
|
||||
dateRange,
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function queryOverview({
|
||||
dateRange = "month",
|
||||
month = "",
|
||||
deptId = "",
|
||||
userId = "",
|
||||
includeTaskProgress = false,
|
||||
} = {}) {
|
||||
const timestamp = nowTs();
|
||||
const includeStr = String(Boolean(includeTaskProgress));
|
||||
|
||||
const payload = [
|
||||
"analytics_overview",
|
||||
APP_KEY,
|
||||
dateRange,
|
||||
month || "",
|
||||
String(deptId || ""),
|
||||
String(userId || ""),
|
||||
includeStr,
|
||||
timestamp,
|
||||
].join("|");
|
||||
const sign = hmacSha256Hex(APP_SECRET, payload);
|
||||
|
||||
return postJson("/api/open/analytics/overview", {
|
||||
appKey: APP_KEY,
|
||||
timestamp,
|
||||
sign,
|
||||
dateRange,
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
userId: userId || undefined,
|
||||
includeTaskProgress: Boolean(includeTaskProgress),
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const mode = process.argv[2] || "progress";
|
||||
|
||||
if (mode === "progress") {
|
||||
const data = await queryTaskProgressOverview({
|
||||
dateRange: process.env.ANALYTICS_DATE_RANGE || "month",
|
||||
month: process.env.ANALYTICS_MONTH || "",
|
||||
});
|
||||
console.log("任务进度总览查询成功:");
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "overview") {
|
||||
const data = await queryOverview({
|
||||
dateRange: process.env.ANALYTICS_DATE_RANGE || "month",
|
||||
month: process.env.ANALYTICS_MONTH || "",
|
||||
includeTaskProgress: String(process.env.ANALYTICS_INCLUDE_TASK_PROGRESS || "false").toLowerCase() === "true",
|
||||
});
|
||||
console.log("数据分析总览查询成功:");
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`未知模式: ${mode},可用 progress / overview`);
|
||||
} catch (err) {
|
||||
console.error("查询失败:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
257
remote_sync/dalongxia_open_analytics.js
Executable file
257
remote_sync/dalongxia_open_analytics.js
Executable file
@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
// file: dalongxia_open_analytics.js
|
||||
// Node 18+
|
||||
// 用法示例:
|
||||
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js designerPerformance
|
||||
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js dimensionAnalysis
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai";
|
||||
const APP_KEY = process.env.OPEN_API_APP_KEY;
|
||||
const APP_SECRET = process.env.OPEN_API_APP_SECRET;
|
||||
|
||||
if (!APP_KEY || !APP_SECRET) {
|
||||
console.error("缺少环境变量 OPEN_API_APP_KEY / OPEN_API_APP_SECRET");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function nowTs() {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
function sign(payload) {
|
||||
return crypto.createHmac("sha256", APP_SECRET).update(payload, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function payload(action, ...parts) {
|
||||
return [action, APP_KEY, ...parts].join("|");
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const resp = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json = {};
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch (err) {
|
||||
throw new Error(`HTTP=${resp.status}, 非JSON响应: ${text.slice(0, 300)}`);
|
||||
}
|
||||
if (!resp.ok || json.code !== 200) {
|
||||
throw new Error(`HTTP=${resp.status}, code=${json.code}, msg=${json.message}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function callOverview({ dateRange = "month", month = "", deptId = "", userId = "", includeTaskProgress = false } = {}) {
|
||||
const ts = nowTs();
|
||||
const includeStr = String(Boolean(includeTaskProgress));
|
||||
const p = payload("analytics_overview", dateRange, month, String(deptId), String(userId), includeStr, ts);
|
||||
return post("/api/open/analytics/overview", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
dateRange,
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
userId: userId || undefined,
|
||||
includeTaskProgress: !!includeTaskProgress,
|
||||
});
|
||||
}
|
||||
|
||||
async function callTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_task_progress_overview", dateRange, month, String(deptId), ts);
|
||||
return post("/api/open/analytics/task-progress/overview", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
dateRange,
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function callDemandStatistics({ dateRange = "month", month = "", deptId = "", userId = "" } = {}) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_demand_statistics", dateRange, month, String(deptId), String(userId), ts);
|
||||
return post("/api/open/analytics/demand-statistics", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
dateRange,
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
userId: userId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function callDesignerPerformance({ month = "", deptId = "" } = {}) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_designer_performance", month, String(deptId), ts);
|
||||
return post("/api/open/analytics/designer-performance", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
month,
|
||||
deptId: deptId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function callProcessEfficiency({ month = "" } = {}) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_process_efficiency", month, ts);
|
||||
return post("/api/open/analytics/process-efficiency", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
month,
|
||||
});
|
||||
}
|
||||
|
||||
async function callWarnings() {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_warnings", ts);
|
||||
return post("/api/open/analytics/warnings", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
});
|
||||
}
|
||||
|
||||
async function callDimensionAnalysis({ dateRange = "month", month = "" } = {}) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_dimension_analysis", dateRange, month, ts);
|
||||
return post("/api/open/analytics/dimension-analysis", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
dateRange,
|
||||
month,
|
||||
});
|
||||
}
|
||||
|
||||
async function callDesignerDeductionDetails({ userId, month = "" }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_designer_deduction_details", String(userId), month, ts);
|
||||
return post("/api/open/analytics/designer-deduction-details", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
userId,
|
||||
month,
|
||||
});
|
||||
}
|
||||
|
||||
async function callDesignerScoreDetails({ userId, month = "" }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_designer_score_details", String(userId), month, ts);
|
||||
return post("/api/open/analytics/designer-score-details", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
userId,
|
||||
month,
|
||||
});
|
||||
}
|
||||
|
||||
async function callPrimaryDeptStatistics({ startTime, endTime, deptId = "" }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_primary_dept_statistics", startTime, endTime, String(deptId), ts);
|
||||
return post("/api/open/analytics/primary-dept/statistics", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
startTime,
|
||||
endTime,
|
||||
deptId: deptId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function callPrimaryDeptRanking({ startTime, endTime }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_primary_dept_ranking", startTime, endTime, ts);
|
||||
return post("/api/open/analytics/primary-dept/ranking", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
async function callPrimaryTaskFlowEfficiency({ startTime, endTime }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_primary_task_flow_efficiency", startTime, endTime, ts);
|
||||
return post("/api/open/analytics/primary-task/flow-efficiency", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
async function callPrimaryTaskBrandDistribution({ startTime, endTime }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_primary_task_brand_distribution", startTime, endTime, ts);
|
||||
return post("/api/open/analytics/primary-task/brand-distribution", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
async function callPrimaryDeptUserRanking({ startTime, endTime }) {
|
||||
const ts = nowTs();
|
||||
const p = payload("analytics_primary_dept_user_ranking", startTime, endTime, ts);
|
||||
return post("/api/open/analytics/primary-dept/user-ranking", {
|
||||
appKey: APP_KEY,
|
||||
timestamp: ts,
|
||||
sign: sign(p),
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const mode = process.argv[2] || "designerPerformance";
|
||||
const month = process.argv[3] || "2026-02";
|
||||
const startTime = process.argv[4] || "2026-02-01T00:00:00";
|
||||
const endTime = process.argv[5] || "2026-02-27T23:59:59";
|
||||
|
||||
const map = {
|
||||
overview: () => callOverview({ month }),
|
||||
taskProgressOverview: () => callTaskProgressOverview({ month }),
|
||||
demandStatistics: () => callDemandStatistics({ month }),
|
||||
designerPerformance: () => callDesignerPerformance({ month }),
|
||||
processEfficiency: () => callProcessEfficiency({ month }),
|
||||
warnings: () => callWarnings(),
|
||||
dimensionAnalysis: () => callDimensionAnalysis({ month }),
|
||||
designerDeduction: () => callDesignerDeductionDetails({ userId: 38, month }),
|
||||
designerScore: () => callDesignerScoreDetails({ userId: 38, month }),
|
||||
primaryDeptStatistics: () => callPrimaryDeptStatistics({ startTime, endTime }),
|
||||
primaryDeptRanking: () => callPrimaryDeptRanking({ startTime, endTime }),
|
||||
primaryTaskFlowEfficiency: () => callPrimaryTaskFlowEfficiency({ startTime, endTime }),
|
||||
primaryTaskBrandDistribution: () => callPrimaryTaskBrandDistribution({ startTime, endTime }),
|
||||
primaryDeptUserRanking: () => callPrimaryDeptUserRanking({ startTime, endTime }),
|
||||
};
|
||||
|
||||
if (!map[mode]) {
|
||||
console.error("未知 mode:", mode);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await map[mode]();
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
})().catch((err) => {
|
||||
console.error("查询失败:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
17
remote_sync/install-media-lookup-wrapper
Executable file
17
remote_sync/install-media-lookup-wrapper
Executable file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VENV="/root/.openclaw/.venv-db"
|
||||
TOOL="/root/.openclaw/workspace/sqlserver/install_media_lookup.py"
|
||||
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
echo "missing python runtime: $VENV/bin/python" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$TOOL" ]; then
|
||||
echo "missing tool script: $TOOL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$VENV/bin/python" "$TOOL" "$@"
|
||||
56
remote_sync/install-media-lookup/SKILL.md
Normal file
56
remote_sync/install-media-lookup/SKILL.md
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
name: install-media-lookup
|
||||
description: Query install drawing and install video links by order id. First map order id to product number from SQLServer, then query MySQL install-media database and return browser-accessible URLs.
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🧩", "os": ["linux"], "requires": { "bins": ["company-db", "install-media-lookup"] } } }
|
||||
---
|
||||
|
||||
# install-media-lookup Skill (OpenClaw)
|
||||
|
||||
Use this skill when user asks:
|
||||
- 根据订单号查询安装图纸
|
||||
- 根据订单号查询安装视频
|
||||
- 查询安装说明书图片地址/视频地址
|
||||
- 订单号 -> 产品货号 -> 安装资源链接
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
install-media-lookup --order-id "<商品订单号>"
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
install-media-lookup --order-id "<商品订单号>" --limit 200
|
||||
install-media-lookup --order-id "<商品订单号>" --mysql-schema "<schema>" --mysql-table "<table>"
|
||||
install-media-lookup --product-no "<产品货号>"
|
||||
install-media-lookup --sku-id "<SKU编码>"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Resolve `order-id` to `product_no` from SQLServer (`jst_goods_profit_kpi_items_afs` + `jst_sku`).
|
||||
2. Query MySQL install-media database by `product_no`.
|
||||
3. Return drawing/video records and convert local path to browser URL:
|
||||
- `D:\JianSunTuZhiFile\...` -> `https://img.jiansuntuzhi.cn/...`
|
||||
4. Output three result sets:
|
||||
- `order_to_product_mapping`
|
||||
- `mysql_table_match_summary`
|
||||
- `install_media_links`
|
||||
|
||||
## Config
|
||||
|
||||
- SQLServer creds (already used by existing `company-db`):
|
||||
- `/root/.openclaw/credentials/company-sql.env`
|
||||
- MySQL creds:
|
||||
- `/root/.openclaw/credentials/install-media-mysql.env`
|
||||
- Optional fixed MySQL table mapping (preferred in production):
|
||||
- `/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json`
|
||||
|
||||
## Rules
|
||||
|
||||
1. If user gives both order id and product number, trust both and merge results.
|
||||
2. If MySQL auto discovery finds nothing, ask to补充/校准映射文件,不要编造链接。
|
||||
3. Always return both `raw_path` and converted `media_url`.
|
||||
4. Never expose DB password in chat output.
|
||||
859
remote_sync/install_media_lookup.py
Normal file
859
remote_sync/install_media_lookup.py
Normal file
@ -0,0 +1,859 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, List, Sequence, Tuple
|
||||
|
||||
import pytds
|
||||
import pymysql
|
||||
|
||||
SQL_ENV_FILE = "/root/.openclaw/credentials/company-sql.env"
|
||||
MYSQL_ENV_FILE = "/root/.openclaw/credentials/install-media-mysql.env"
|
||||
DEFAULT_MAPPING_FILE = "/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json"
|
||||
|
||||
|
||||
def load_env_file(path: str) -> None:
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
return
|
||||
for raw in p.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
def sql_conn_params() -> Dict[str, object]:
|
||||
load_env_file(SQL_ENV_FILE)
|
||||
return {
|
||||
"server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"),
|
||||
"port": int(os.environ.get("COMPANY_DB_PORT", "1433")),
|
||||
"database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"),
|
||||
"user": os.environ.get("COMPANY_DB_USER", "nanting"),
|
||||
"password": os.environ.get("COMPANY_DB_PASSWORD", ""),
|
||||
"login_timeout": 15,
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
def mysql_conn_params() -> Dict[str, object]:
|
||||
load_env_file(MYSQL_ENV_FILE)
|
||||
return {
|
||||
"host": os.environ.get("INSTALL_MEDIA_MYSQL_HOST", "218.93.191.20"),
|
||||
"port": int(os.environ.get("INSTALL_MEDIA_MYSQL_PORT", "3306")),
|
||||
"user": os.environ.get("INSTALL_MEDIA_MYSQL_USER", "read_only_jiansun"),
|
||||
"password": os.environ.get("INSTALL_MEDIA_MYSQL_PASSWORD", ""),
|
||||
"charset": "utf8mb4",
|
||||
"connect_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_CONNECT_TIMEOUT", "8")),
|
||||
"read_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_READ_TIMEOUT", "15")),
|
||||
"write_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_WRITE_TIMEOUT", "15")),
|
||||
"autocommit": True,
|
||||
}
|
||||
|
||||
|
||||
def sql_escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def qi(identifier: str) -> str:
|
||||
return "`" + identifier.replace("`", "``") + "`"
|
||||
|
||||
|
||||
def fmt_value(value):
|
||||
if isinstance(value, (dt.datetime, dt.date, dt.time)):
|
||||
return value.isoformat(sep=" ")
|
||||
return value
|
||||
|
||||
|
||||
def print_rows(columns: Sequence[str], rows: Sequence[Dict[str, object]]) -> None:
|
||||
print("COLUMNS:", ", ".join(columns))
|
||||
if not rows:
|
||||
print("ROWS: 0")
|
||||
return
|
||||
print(f"ROWS: {len(rows)}")
|
||||
for row in rows:
|
||||
obj = {c: fmt_value(row.get(c)) for c in columns}
|
||||
print(json.dumps(obj, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def query_order_product_mappings(order_id: str, limit: int) -> List[Dict[str, object]]:
|
||||
if not order_id:
|
||||
return []
|
||||
|
||||
params = sql_conn_params()
|
||||
if not params["password"]:
|
||||
raise RuntimeError("COMPANY_DB_PASSWORD is empty")
|
||||
|
||||
oid = sql_escape(order_id.strip())
|
||||
lim = max(1, min(limit, 2000))
|
||||
|
||||
sql = f"""
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @order_id nvarchar(120) = N'{oid}';
|
||||
|
||||
WITH order_map AS (
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, '')) AS product_no,
|
||||
COALESCE(NULLIF(s.name, ''), NULLIF(a.undefined36, '')) AS sku_name,
|
||||
COALESCE(NULLIF(a.undefined8, ''), NULLIF(e.shop_name, '')) AS shop_name,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS send_time,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(a.undefined2, ''), COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), NULLIF(a.undefined10, ''))
|
||||
ORDER BY COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) DESC,
|
||||
a.createdAt DESC
|
||||
) AS rn
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
|
||||
ON s.sku_id = NULLIF(a.undefined10, '')
|
||||
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
|
||||
ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '')
|
||||
WHERE (
|
||||
NULLIF(a.undefined2, '') = @order_id
|
||||
OR NULLIF(a.undefined3, '') = @order_id
|
||||
OR NULLIF(a.undefined3, '') LIKE @order_id + '%'
|
||||
)
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
|
||||
)
|
||||
SELECT TOP ({lim})
|
||||
order_id,
|
||||
sku_id,
|
||||
product_no,
|
||||
sku_name,
|
||||
shop_name,
|
||||
send_time
|
||||
FROM order_map
|
||||
WHERE rn = 1
|
||||
AND ISNULL(NULLIF(order_id, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(product_no, ''), '') <> ''
|
||||
ORDER BY send_time DESC, order_id ASC;
|
||||
"""
|
||||
|
||||
with pytds.connect(**params) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [c[0] for c in cur.description]
|
||||
data = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
cur.close()
|
||||
return data
|
||||
|
||||
|
||||
def _normalize_text(s: str) -> str:
|
||||
return (s or "").strip().lower()
|
||||
|
||||
|
||||
PRODUCT_COL_KEYS = [
|
||||
"productno",
|
||||
"product_no",
|
||||
"goodsno",
|
||||
"goods_no",
|
||||
"itemno",
|
||||
"item_no",
|
||||
"stylecode",
|
||||
"style_code",
|
||||
"spu",
|
||||
"sku",
|
||||
"barcode",
|
||||
"procode",
|
||||
"pro_code",
|
||||
"货号",
|
||||
"商品编码",
|
||||
"产品编码",
|
||||
]
|
||||
|
||||
DRAWING_COL_KEYS = [
|
||||
"instruction",
|
||||
"drawing",
|
||||
"manual",
|
||||
"install",
|
||||
"tuzhi",
|
||||
"图纸",
|
||||
"安装图",
|
||||
"说明书",
|
||||
]
|
||||
|
||||
VIDEO_COL_KEYS = [
|
||||
"video",
|
||||
"mp4",
|
||||
"movie",
|
||||
"教程",
|
||||
"视频",
|
||||
"安装视频",
|
||||
]
|
||||
|
||||
PATH_COL_KEYS = [
|
||||
"path",
|
||||
"url",
|
||||
"file",
|
||||
"img",
|
||||
"image",
|
||||
"地址",
|
||||
"链接",
|
||||
"文件",
|
||||
"路径",
|
||||
]
|
||||
|
||||
SYSTEM_SCHEMAS = {"information_schema", "mysql", "performance_schema", "sys"}
|
||||
|
||||
|
||||
def _contains_any(text: str, keys: Sequence[str]) -> bool:
|
||||
low = _normalize_text(text)
|
||||
return any(k in low for k in keys)
|
||||
|
||||
|
||||
def discover_table_specs(
|
||||
conn,
|
||||
explicit_schema: str,
|
||||
explicit_table: str,
|
||||
max_tables: int,
|
||||
) -> List[Dict[str, object]]:
|
||||
where = ["TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')"]
|
||||
params: List[object] = []
|
||||
if explicit_schema:
|
||||
where.append("TABLE_SCHEMA = %s")
|
||||
params.append(explicit_schema)
|
||||
if explicit_table:
|
||||
where.append("TABLE_NAME = %s")
|
||||
params.append(explicit_table)
|
||||
|
||||
sql = f"""
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COALESCE(COLUMN_COMMENT, '')
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION
|
||||
"""
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
grouped: Dict[Tuple[str, str], List[Tuple[str, str, str]]] = {}
|
||||
for schema, table, col, dtype, comment in rows:
|
||||
key = (schema, table)
|
||||
grouped.setdefault(key, []).append((str(col), str(dtype), str(comment or "")))
|
||||
|
||||
specs: List[Dict[str, object]] = []
|
||||
for (schema, table), cols in grouped.items():
|
||||
product_cols: List[str] = []
|
||||
drawing_cols: List[str] = []
|
||||
video_cols: List[str] = []
|
||||
generic_media_cols: List[str] = []
|
||||
|
||||
for col_name, _dtype, col_comment in cols:
|
||||
text = f"{col_name} {col_comment}"
|
||||
has_path = _contains_any(text, PATH_COL_KEYS)
|
||||
has_drawing = _contains_any(text, DRAWING_COL_KEYS)
|
||||
has_video = _contains_any(text, VIDEO_COL_KEYS)
|
||||
has_product = _contains_any(text, PRODUCT_COL_KEYS)
|
||||
|
||||
if has_product:
|
||||
product_cols.append(col_name)
|
||||
|
||||
if has_drawing and (has_path or has_drawing):
|
||||
drawing_cols.append(col_name)
|
||||
elif has_video and (has_path or has_video):
|
||||
video_cols.append(col_name)
|
||||
elif has_path and (has_drawing or has_video):
|
||||
generic_media_cols.append(col_name)
|
||||
elif has_path and _contains_any(text, ["install", "instruction", "图纸", "视频"]):
|
||||
generic_media_cols.append(col_name)
|
||||
|
||||
media_cols = sorted(set(drawing_cols + video_cols + generic_media_cols))
|
||||
if not product_cols or not media_cols:
|
||||
continue
|
||||
|
||||
specs.append(
|
||||
{
|
||||
"schema": schema,
|
||||
"table": table,
|
||||
"product_cols": sorted(set(product_cols)),
|
||||
"drawing_cols": sorted(set(drawing_cols)),
|
||||
"video_cols": sorted(set(video_cols)),
|
||||
"media_cols": media_cols,
|
||||
}
|
||||
)
|
||||
|
||||
specs.sort(key=lambda x: (x["schema"], x["table"]))
|
||||
if max_tables > 0:
|
||||
specs = specs[:max_tables]
|
||||
return specs
|
||||
|
||||
|
||||
def load_mapping_specs(mapping_file: str) -> List[Dict[str, object]]:
|
||||
p = pathlib.Path(mapping_file)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
obj = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
rows = obj.get("tables") if isinstance(obj, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
|
||||
specs: List[Dict[str, object]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
schema = str(row.get("schema", "")).strip()
|
||||
table = str(row.get("table", "")).strip()
|
||||
product_cols = [str(x).strip() for x in row.get("product_cols", []) if str(x).strip()]
|
||||
drawing_cols = [str(x).strip() for x in row.get("drawing_cols", []) if str(x).strip()]
|
||||
video_cols = [str(x).strip() for x in row.get("video_cols", []) if str(x).strip()]
|
||||
media_cols = [str(x).strip() for x in row.get("media_cols", []) if str(x).strip()]
|
||||
if not schema or not table or not product_cols:
|
||||
continue
|
||||
merged_media = sorted(set(media_cols + drawing_cols + video_cols))
|
||||
if not merged_media:
|
||||
continue
|
||||
specs.append(
|
||||
{
|
||||
"schema": schema,
|
||||
"table": table,
|
||||
"product_cols": sorted(set(product_cols)),
|
||||
"drawing_cols": sorted(set(drawing_cols)),
|
||||
"video_cols": sorted(set(video_cols)),
|
||||
"media_cols": merged_media,
|
||||
}
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def classify_media_type(col_name: str, value: str) -> str:
|
||||
text = _normalize_text(f"{col_name} {value}")
|
||||
if _contains_any(text, VIDEO_COL_KEYS) or re.search(r"\.(mp4|avi|mov|mkv|wmv)(\?|$)", text):
|
||||
return "video"
|
||||
if _contains_any(text, DRAWING_COL_KEYS) or re.search(r"\.(png|jpg|jpeg|webp|gif|bmp)(\?|$)", text):
|
||||
return "drawing"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def normalize_media_url(raw_path: str, local_prefix: str, cdn_prefix: str) -> str:
|
||||
s = (raw_path or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
if s.startswith("http://") or s.startswith("https://"):
|
||||
return s
|
||||
|
||||
s_norm = s.replace("\\", "/")
|
||||
local_norm = (local_prefix or "D:/JianSunTuZhiFile").replace("\\", "/").rstrip("/")
|
||||
cdn_norm = (cdn_prefix or "https://img.jiansuntuzhi.cn/").rstrip("/") + "/"
|
||||
|
||||
if s_norm.lower().startswith(local_norm.lower() + "/"):
|
||||
rest = s_norm[len(local_norm) + 1 :]
|
||||
return cdn_norm + rest
|
||||
if s_norm.lower() == local_norm.lower():
|
||||
return cdn_norm.rstrip("/")
|
||||
|
||||
marker = "jiansuntuzhifile/"
|
||||
idx = s_norm.lower().find(marker)
|
||||
if idx >= 0:
|
||||
rest = s_norm[idx + len(marker) :]
|
||||
return cdn_norm + rest
|
||||
|
||||
if re.match(r"^[a-zA-Z]:/", s_norm):
|
||||
return s_norm
|
||||
|
||||
if s_norm.startswith("/"):
|
||||
return cdn_norm + s_norm.lstrip("/")
|
||||
|
||||
return cdn_norm + s_norm
|
||||
|
||||
|
||||
def _query_table_rows(conn, sql: str, params: Sequence[object]) -> List[Dict[str, object]]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
cols = [d[0] for d in cur.description]
|
||||
data = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
return data
|
||||
|
||||
|
||||
def query_jiansun_special_records(
|
||||
conn,
|
||||
schema: str,
|
||||
product_no: str,
|
||||
row_limit: int,
|
||||
local_prefix: str,
|
||||
cdn_prefix: str,
|
||||
) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
|
||||
"""
|
||||
Deterministic path for jiansunblueprint:
|
||||
product_no -> sheet1 -> missiontask -> missiontask_instructions/missiontask_video
|
||||
"""
|
||||
table_rows: List[Dict[str, object]] = []
|
||||
detail_rows: List[Dict[str, object]] = []
|
||||
|
||||
if not schema:
|
||||
return table_rows, detail_rows
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW TABLES FROM {qi(schema)}")
|
||||
table_names = {str(r[0]).lower() for r in cur.fetchall()}
|
||||
|
||||
required = {"sheet1", "missiontask", "missiontask_instructions", "missiontask_video"}
|
||||
if not required.issubset(table_names):
|
||||
return table_rows, detail_rows
|
||||
|
||||
lim = max(1, min(row_limit, 2000))
|
||||
pno = product_no.strip()
|
||||
|
||||
# 1) sheet1: map product_no -> barcode/pictureId/sku model
|
||||
sheet_sql = (
|
||||
f"SELECT {qi('sku(货号)')} AS product_no, {qi('工厂条形码')} AS barcode, "
|
||||
f"{qi('图纸ID')} AS picture_id, {qi('SKU型号')} AS sku_model "
|
||||
f"FROM {qi(schema)}.{qi('sheet1')} "
|
||||
f"WHERE {qi('sku(货号)')} = %s OR {qi('sku(货号)')} LIKE %s "
|
||||
f"LIMIT %s"
|
||||
)
|
||||
sheet_rows = _query_table_rows(conn, sheet_sql, (pno, f"{pno}%", lim))
|
||||
table_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"source_schema": schema,
|
||||
"source_table": "sheet1",
|
||||
"matched_rows": len(sheet_rows),
|
||||
"matched_media_items": 0,
|
||||
}
|
||||
)
|
||||
|
||||
barcodes = sorted({str(r.get("barcode", "")).strip() for r in sheet_rows if str(r.get("barcode", "")).strip()})
|
||||
picture_ids = sorted({str(r.get("picture_id", "")).strip() for r in sheet_rows if str(r.get("picture_id", "")).strip()})
|
||||
sku_models = sorted({str(r.get("sku_model", "")).strip() for r in sheet_rows if str(r.get("sku_model", "")).strip()})
|
||||
|
||||
# 2) missiontask: find task ids
|
||||
match_parts: List[str] = []
|
||||
params: List[object] = []
|
||||
|
||||
match_parts.append(f"{qi('skucodes')} LIKE %s")
|
||||
params.append(f"%{pno}%")
|
||||
|
||||
for b in barcodes[:60]:
|
||||
match_parts.append(f"{qi('skucodes')} LIKE %s")
|
||||
params.append(f"%{b}%")
|
||||
for pid in picture_ids[:60]:
|
||||
match_parts.append(f"{qi('pictureId')} = %s")
|
||||
params.append(pid)
|
||||
for sm in sku_models[:40]:
|
||||
match_parts.append(f"{qi('skuModel')} = %s")
|
||||
params.append(sm)
|
||||
|
||||
where_clause = (
|
||||
f"({qi('isDelete')} = '0' OR {qi('isDelete')} = 0 OR {qi('isDelete')} IS NULL)"
|
||||
f" AND ({' OR '.join(match_parts)})"
|
||||
)
|
||||
|
||||
mission_sql = (
|
||||
f"SELECT {qi('id')} AS task_id, {qi('taskId')} AS task_no, {qi('SPU')} AS spu, "
|
||||
f"{qi('picture')} AS picture_path, {qi('pictureId')} AS picture_id, {qi('skucodes')} AS skucodes, "
|
||||
f"{qi('withInstructions')} AS with_instructions, {qi('withVideo')} AS with_video, "
|
||||
f"{qi('createdTime')} AS created_time "
|
||||
f"FROM {qi(schema)}.{qi('missiontask')} "
|
||||
f"WHERE {where_clause} "
|
||||
f"ORDER BY {qi('createdTime')} DESC "
|
||||
f"LIMIT %s"
|
||||
)
|
||||
params.append(max(20, min(lim * 3, 500)))
|
||||
mission_rows = _query_table_rows(conn, mission_sql, params)
|
||||
table_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"source_schema": schema,
|
||||
"source_table": "missiontask",
|
||||
"matched_rows": len(mission_rows),
|
||||
"matched_media_items": 0,
|
||||
}
|
||||
)
|
||||
|
||||
task_ids = [str(r.get("task_id", "")).strip() for r in mission_rows if str(r.get("task_id", "")).strip()]
|
||||
if not task_ids:
|
||||
return table_rows, detail_rows
|
||||
|
||||
# 3) instructions
|
||||
placeholders = ",".join(["%s"] * len(task_ids))
|
||||
ins_sql = (
|
||||
f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, {qi('orderNum')} AS order_num "
|
||||
f"FROM {qi(schema)}.{qi('missiontask_instructions')} "
|
||||
f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 "
|
||||
f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC"
|
||||
)
|
||||
ins_rows = _query_table_rows(conn, ins_sql, task_ids)
|
||||
for r in ins_rows:
|
||||
raw_path = str(r.get("raw_path", "")).strip()
|
||||
if not raw_path:
|
||||
continue
|
||||
detail_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"media_type": "drawing",
|
||||
"media_url": normalize_media_url(raw_path, local_prefix, cdn_prefix),
|
||||
"raw_path": raw_path,
|
||||
"source_schema": schema,
|
||||
"source_table": "missiontask_instructions",
|
||||
"source_column": "url",
|
||||
}
|
||||
)
|
||||
|
||||
# 4) video
|
||||
video_sql = (
|
||||
f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, "
|
||||
f"{qi('videoId')} AS video_id, {qi('orderNum')} AS order_num "
|
||||
f"FROM {qi(schema)}.{qi('missiontask_video')} "
|
||||
f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 "
|
||||
f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC"
|
||||
)
|
||||
video_rows = _query_table_rows(conn, video_sql, task_ids)
|
||||
for r in video_rows:
|
||||
raw_path = str(r.get("raw_path", "")).strip()
|
||||
video_id = str(r.get("video_id", "")).strip()
|
||||
media_url = ""
|
||||
source_col = "url"
|
||||
if raw_path:
|
||||
media_url = normalize_media_url(raw_path, local_prefix, cdn_prefix)
|
||||
elif video_id:
|
||||
# youku id format: XNjUyMTkzNjkxMg==
|
||||
media_url = f"https://v.youku.com/v_show/id_{video_id}.html"
|
||||
raw_path = video_id
|
||||
source_col = "videoId"
|
||||
else:
|
||||
continue
|
||||
|
||||
detail_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"media_type": "video",
|
||||
"media_url": media_url,
|
||||
"raw_path": raw_path,
|
||||
"source_schema": schema,
|
||||
"source_table": "missiontask_video",
|
||||
"source_column": source_col,
|
||||
}
|
||||
)
|
||||
|
||||
table_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"source_schema": schema,
|
||||
"source_table": "missiontask_instructions",
|
||||
"matched_rows": len(ins_rows),
|
||||
"matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_instructions"),
|
||||
}
|
||||
)
|
||||
table_rows.append(
|
||||
{
|
||||
"product_no": pno,
|
||||
"source_schema": schema,
|
||||
"source_table": "missiontask_video",
|
||||
"matched_rows": len(video_rows),
|
||||
"matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_video"),
|
||||
}
|
||||
)
|
||||
return table_rows, detail_rows
|
||||
|
||||
|
||||
def query_media_records(
|
||||
conn,
|
||||
specs: Sequence[Dict[str, object]],
|
||||
product_no: str,
|
||||
row_limit: int,
|
||||
local_prefix: str,
|
||||
cdn_prefix: str,
|
||||
) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
|
||||
detail_rows: List[Dict[str, object]] = []
|
||||
table_rows: List[Dict[str, object]] = []
|
||||
|
||||
for spec in specs:
|
||||
schema = str(spec["schema"])
|
||||
table = str(spec["table"])
|
||||
product_cols = list(spec["product_cols"])
|
||||
media_cols = list(spec["media_cols"])
|
||||
drawing_cols = set(spec.get("drawing_cols", []))
|
||||
video_cols = set(spec.get("video_cols", []))
|
||||
|
||||
select_cols = sorted(set(product_cols + media_cols))
|
||||
where_parts: List[str] = []
|
||||
params: List[object] = []
|
||||
for col in product_cols:
|
||||
qcol = qi(col)
|
||||
where_parts.append(f"({qcol} = %s OR {qcol} LIKE %s)")
|
||||
params.extend([product_no, f"{product_no}%"])
|
||||
|
||||
if not where_parts:
|
||||
continue
|
||||
|
||||
sql = (
|
||||
f"SELECT {', '.join(qi(c) for c in select_cols)} "
|
||||
f"FROM {qi(schema)}.{qi(table)} "
|
||||
f"WHERE {' OR '.join(where_parts)} "
|
||||
f"LIMIT %s"
|
||||
)
|
||||
params.append(max(1, min(row_limit, 2000)))
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
data = cur.fetchall()
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
col_index = {name: i for i, name in enumerate(select_cols)}
|
||||
matched_media = 0
|
||||
|
||||
for row in data:
|
||||
product_val = ""
|
||||
for pcol in product_cols:
|
||||
value = row[col_index[pcol]]
|
||||
if value is not None and str(value).strip():
|
||||
product_val = str(value).strip()
|
||||
break
|
||||
|
||||
for mcol in media_cols:
|
||||
value = row[col_index[mcol]]
|
||||
if value is None:
|
||||
continue
|
||||
raw_path = str(value).strip()
|
||||
if not raw_path:
|
||||
continue
|
||||
|
||||
media_type = classify_media_type(mcol, raw_path)
|
||||
if mcol in drawing_cols:
|
||||
media_type = "drawing"
|
||||
elif mcol in video_cols:
|
||||
media_type = "video"
|
||||
|
||||
normalized = normalize_media_url(raw_path, local_prefix, cdn_prefix)
|
||||
detail_rows.append(
|
||||
{
|
||||
"product_no": product_val or product_no,
|
||||
"media_type": media_type,
|
||||
"media_url": normalized,
|
||||
"raw_path": raw_path,
|
||||
"source_schema": schema,
|
||||
"source_table": table,
|
||||
"source_column": mcol,
|
||||
}
|
||||
)
|
||||
matched_media += 1
|
||||
|
||||
table_rows.append(
|
||||
{
|
||||
"product_no": product_no,
|
||||
"source_schema": schema,
|
||||
"source_table": table,
|
||||
"matched_rows": len(data),
|
||||
"matched_media_items": matched_media,
|
||||
}
|
||||
)
|
||||
|
||||
return table_rows, detail_rows
|
||||
|
||||
|
||||
def dedupe_media_rows(rows: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
|
||||
seen = set()
|
||||
out: List[Dict[str, object]] = []
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("product_no", ""),
|
||||
row.get("media_type", ""),
|
||||
row.get("media_url", ""),
|
||||
row.get("raw_path", ""),
|
||||
row.get("source_schema", ""),
|
||||
row.get("source_table", ""),
|
||||
row.get("source_column", ""),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(dict(row))
|
||||
return out
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Lookup install drawing/video links by order-id -> product-no -> MySQL media DB"
|
||||
)
|
||||
parser.add_argument("--order-id", default="", help="商品订单号")
|
||||
parser.add_argument("--product-no", default="", help="产品货号(可跳过订单映射直接查)")
|
||||
parser.add_argument("--sku-id", default="", help="SKU编码(等价于 --product-no,直接在安装库中匹配)")
|
||||
parser.add_argument("--limit", type=int, default=200, help="每个来源表最多查询行数")
|
||||
parser.add_argument("--max-tables", type=int, default=120, help="自动发现时最多探测表数")
|
||||
parser.add_argument("--mysql-schema", default="", help="指定MySQL schema,减少扫描范围")
|
||||
parser.add_argument("--mysql-table", default="", help="指定MySQL table,减少扫描范围")
|
||||
parser.add_argument("--mapping-file", default=DEFAULT_MAPPING_FILE, help="固定映射配置JSON")
|
||||
parser.add_argument("--local-prefix", default=os.environ.get("INSTALL_MEDIA_LOCAL_PREFIX", "D:\\JianSunTuZhiFile"), help="本地路径前缀")
|
||||
parser.add_argument("--cdn-prefix", default=os.environ.get("INSTALL_MEDIA_CDN_PREFIX", "https://img.jiansuntuzhi.cn/"), help="图纸/视频CDN前缀")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
order_id = args.order_id.strip()
|
||||
direct_product = args.product_no.strip() or args.sku_id.strip()
|
||||
|
||||
if not order_id and not direct_product:
|
||||
print("STATUS: failed")
|
||||
print("ERROR: missing order-id or product-no")
|
||||
return 2
|
||||
|
||||
warnings: List[str] = []
|
||||
mapping_rows: List[Dict[str, object]] = []
|
||||
|
||||
if order_id:
|
||||
try:
|
||||
mapping_rows.extend(query_order_product_mappings(order_id, limit=300))
|
||||
except Exception as exc:
|
||||
warnings.append(f"SQLSERVER_ORDER_MAPPING_FAILED: {exc}")
|
||||
|
||||
if direct_product:
|
||||
mapping_rows.append(
|
||||
{
|
||||
"order_id": order_id or "(direct)",
|
||||
"sku_id": "",
|
||||
"product_no": direct_product,
|
||||
"sku_name": "",
|
||||
"shop_name": "",
|
||||
"send_time": None,
|
||||
}
|
||||
)
|
||||
|
||||
# dedupe mapping by (order_id, product_no, sku_id)
|
||||
dedup = {}
|
||||
for row in mapping_rows:
|
||||
k = (str(row.get("order_id", "")).strip(), str(row.get("product_no", "")).strip(), str(row.get("sku_id", "")).strip())
|
||||
if not k[1]:
|
||||
continue
|
||||
dedup[k] = row
|
||||
mapping_rows = list(dedup.values())
|
||||
|
||||
product_nos = sorted({str(r.get("product_no", "")).strip() for r in mapping_rows if str(r.get("product_no", "")).strip()})
|
||||
|
||||
table_summary_rows: List[Dict[str, object]] = []
|
||||
media_rows: List[Dict[str, object]] = []
|
||||
|
||||
if product_nos:
|
||||
mysql_params = mysql_conn_params()
|
||||
if not mysql_params["password"]:
|
||||
warnings.append("MYSQL_PASSWORD_EMPTY: please set INSTALL_MEDIA_MYSQL_PASSWORD")
|
||||
else:
|
||||
try:
|
||||
with pymysql.connect(**mysql_params) as conn:
|
||||
schema_hint = args.mysql_schema.strip() or "jiansunblueprint"
|
||||
source_mode = "special_jiansun"
|
||||
discovered_count = 0
|
||||
|
||||
for pno in product_nos:
|
||||
ts, ms = query_jiansun_special_records(
|
||||
conn=conn,
|
||||
schema=schema_hint,
|
||||
product_no=pno,
|
||||
row_limit=max(1, args.limit),
|
||||
local_prefix=args.local_prefix,
|
||||
cdn_prefix=args.cdn_prefix,
|
||||
)
|
||||
table_summary_rows.extend(ts)
|
||||
media_rows.extend(ms)
|
||||
|
||||
if not ms:
|
||||
specs = load_mapping_specs(args.mapping_file)
|
||||
if specs:
|
||||
source_mode = "mapping_file_fallback"
|
||||
else:
|
||||
specs = discover_table_specs(
|
||||
conn,
|
||||
args.mysql_schema.strip(),
|
||||
args.mysql_table.strip(),
|
||||
max(1, args.max_tables),
|
||||
)
|
||||
source_mode = "auto_discovery_fallback"
|
||||
discovered_count = len(specs)
|
||||
ts2, ms2 = query_media_records(
|
||||
conn=conn,
|
||||
specs=specs,
|
||||
product_no=pno,
|
||||
row_limit=max(1, args.limit),
|
||||
local_prefix=args.local_prefix,
|
||||
cdn_prefix=args.cdn_prefix,
|
||||
)
|
||||
table_summary_rows.extend(ts2)
|
||||
media_rows.extend(ms2)
|
||||
|
||||
print(f"DISCOVERY_MODE: {source_mode}")
|
||||
print(f"DISCOVERED_TABLES: {discovered_count}")
|
||||
except Exception as exc:
|
||||
warnings.append(f"MYSQL_LOOKUP_FAILED: {exc}")
|
||||
else:
|
||||
warnings.append("NO_PRODUCT_NO_FROM_ORDER: no product_no mapped from order-id")
|
||||
|
||||
media_rows = dedupe_media_rows(media_rows)
|
||||
|
||||
status = "ok"
|
||||
if not media_rows and warnings:
|
||||
status = "partial"
|
||||
if not media_rows and not product_nos:
|
||||
status = "failed"
|
||||
|
||||
print(f"STATUS: {status}")
|
||||
print(f"ORDER_ID: {order_id or '(none)'}")
|
||||
print(f"PRODUCT_NO_COUNT: {len(product_nos)}")
|
||||
|
||||
print("RESULT_SET 1")
|
||||
print("NAME: order_to_product_mapping")
|
||||
print_rows(
|
||||
["order_id", "sku_id", "product_no", "sku_name", "shop_name", "send_time"],
|
||||
mapping_rows,
|
||||
)
|
||||
|
||||
print("RESULT_SET 2")
|
||||
print("NAME: mysql_table_match_summary")
|
||||
print_rows(
|
||||
["product_no", "source_schema", "source_table", "matched_rows", "matched_media_items"],
|
||||
table_summary_rows,
|
||||
)
|
||||
|
||||
print("RESULT_SET 3")
|
||||
print("NAME: install_media_links")
|
||||
print_rows(
|
||||
["product_no", "media_type", "media_url", "raw_path", "source_schema", "source_table", "source_column"],
|
||||
media_rows,
|
||||
)
|
||||
|
||||
if warnings:
|
||||
print("WARNINGS:")
|
||||
for w in warnings:
|
||||
print(f"- {w}")
|
||||
|
||||
if not media_rows:
|
||||
print("NEXT_STEP:")
|
||||
if warnings:
|
||||
print("- verify MySQL network reachability and fixed table mapping file")
|
||||
else:
|
||||
print("- matched sku/product but no install drawing/video rows in current tables")
|
||||
|
||||
return 0 if status in {"ok", "partial"} else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
95
remote_sync/last_week_afs_report.sql
Normal file
95
remote_sync/last_week_afs_report.sql
Normal file
@ -0,0 +1,95 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Unified logical layer (temp-table based, no CREATE VIEW permission required).
|
||||
Week window = previous natural week:
|
||||
[start_of_last_week, start_of_this_week)
|
||||
*/
|
||||
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#filtered') IS NOT NULL
|
||||
DROP TABLE #filtered;
|
||||
|
||||
SELECT
|
||||
a.id,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined1, '') AS inner_order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
NULLIF(a.undefined9, '') AS shop_code,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined56, '') AS afs_type,
|
||||
NULLIF(a.undefined57, '') AS afs_reason,
|
||||
NULLIF(a.undefined58, '') AS afs_result,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount,
|
||||
a.createdAt AS created_at,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name
|
||||
INTO #filtered
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @week_start
|
||||
AND a.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
-- 1) weekly overview
|
||||
SELECT
|
||||
MIN(created_at) AS data_min_time,
|
||||
MAX(created_at) AS data_max_time,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount,
|
||||
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered;
|
||||
|
||||
-- 2) platform distribution
|
||||
SELECT
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered
|
||||
GROUP BY platform_name
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 3) shop top 20
|
||||
SELECT TOP (20)
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered
|
||||
GROUP BY shop_name, platform_name
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 4) reason top 20
|
||||
SELECT TOP (20)
|
||||
ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount
|
||||
FROM #filtered
|
||||
GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注')
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 5) type/result matrix
|
||||
SELECT
|
||||
ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result,
|
||||
COUNT(*) AS afs_record_count
|
||||
FROM #filtered
|
||||
GROUP BY
|
||||
ISNULL(NULLIF(afs_type, ''), '未标注'),
|
||||
ISNULL(NULLIF(afs_result, ''), '未标注')
|
||||
ORDER BY afs_record_count DESC;
|
||||
414
remote_sync/market-brief-report
Executable file
414
remote_sync/market-brief-report
Executable file
@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
keyword=${1:-电脑桌}
|
||||
report_scope=${2:-淘宝口径}
|
||||
workdir=/tmp/market-brief
|
||||
mkdir -p "$workdir"
|
||||
profile=${BROWSER_PROFILE:-openclaw}
|
||||
|
||||
capture_snapshot() {
|
||||
local page="$1"
|
||||
local url="$2"
|
||||
local wait_text="${3:-}"
|
||||
local limit="${4:-260}"
|
||||
|
||||
yuce-browser open "$url" >/dev/null 2>&1 || true
|
||||
openclaw browser wait --browser-profile "$profile" --load domcontentloaded --timeout-ms 15000 >/dev/null 2>&1 || true
|
||||
openclaw browser wait --browser-profile "$profile" --time 2000 >/dev/null 2>&1 || true
|
||||
if [ -n "$wait_text" ]; then
|
||||
openclaw browser wait --browser-profile "$profile" --text "$wait_text" --timeout-ms 12000 >/dev/null 2>&1 || true
|
||||
fi
|
||||
openclaw browser snapshot --browser-profile "$profile" --labels --limit "$limit" > "$workdir/${page}.snapshot.txt" || true
|
||||
}
|
||||
|
||||
# Multi-page evidence collection
|
||||
declare -A PAGE_URLS
|
||||
PAGE_URLS[leaf_category]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_TAPE_ANALYSIS/M_TB_TAPE_ANALYSIS_0/report-view/CZqcPFPRsZ'
|
||||
PAGE_URLS[ranking_list]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_RANKING_LIST/M_TB_RANKING_LIST_0/report-view/hEq4a98B7B'
|
||||
PAGE_URLS[industry_distribution]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_INSIGHT/M_TB_INDUSTRY_DISTRIBUTION/report-view/sfQrjDMn5l'
|
||||
PAGE_URLS[surging_rank]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_SURGING_RANK/M_TB_SURGING_RANK_0/report-view/CD6nSlf6pC'
|
||||
|
||||
LOGIN_URL='https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F'
|
||||
HOME_URL='https://hq.yucekj.com/#/home?tab=TAOBAO_MNO'
|
||||
|
||||
yuce-browser open "$LOGIN_URL" >/dev/null 2>&1 || true
|
||||
yuce-browser open "$HOME_URL" >/dev/null 2>&1 || true
|
||||
openclaw browser wait --browser-profile "$profile" --time 1500 >/dev/null 2>&1 || true
|
||||
|
||||
capture_snapshot leaf_category "${PAGE_URLS[leaf_category]}" "销售金额" 360
|
||||
capture_snapshot ranking_list "${PAGE_URLS[ranking_list]}" "榜单" 260
|
||||
capture_snapshot industry_distribution "${PAGE_URLS[industry_distribution]}" "行业" 220
|
||||
capture_snapshot surging_rank "${PAGE_URLS[surging_rank]}" "飙升" 280
|
||||
|
||||
yuce-browser requests --filter yucekj.com > "$workdir/requests.txt" || true
|
||||
|
||||
python3 - "$workdir" "$keyword" "$report_scope" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
workdir = Path(sys.argv[1])
|
||||
keyword = (sys.argv[2] or "").strip()
|
||||
scope = (sys.argv[3] or "").strip()
|
||||
|
||||
pages = {
|
||||
"leaf_category": workdir / "leaf_category.snapshot.txt",
|
||||
"ranking_list": workdir / "ranking_list.snapshot.txt",
|
||||
"industry_distribution": workdir / "industry_distribution.snapshot.txt",
|
||||
"surging_rank": workdir / "surging_rank.snapshot.txt",
|
||||
}
|
||||
page_text = {k: (p.read_text(encoding="utf-8", errors="ignore") if p.exists() else "") for k, p in pages.items()}
|
||||
request_text = (workdir / "requests.txt").read_text(encoding="utf-8", errors="ignore") if (workdir / "requests.txt").exists() else ""
|
||||
|
||||
ROW_RE = re.compile(r'row "([^\"]+)"')
|
||||
PCT_RE = re.compile(r'^-?\d+(?:\.\d+)?%$')
|
||||
|
||||
def norm(s: str) -> str:
|
||||
return re.sub(r"[\s/()()【】\[\]·\-_,,。::;;]", "", (s or "")).lower()
|
||||
|
||||
|
||||
def amount_to_num(s: str):
|
||||
if not s:
|
||||
return None
|
||||
t = s.replace(",", "").strip()
|
||||
mul = 1.0
|
||||
if t.endswith("亿"):
|
||||
mul = 1e8
|
||||
t = t[:-1]
|
||||
elif t.endswith("万"):
|
||||
mul = 1e4
|
||||
t = t[:-1]
|
||||
try:
|
||||
return float(t) * mul
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def pct_to_num(s: str):
|
||||
if not s:
|
||||
return None
|
||||
t = s.strip().replace("%", "")
|
||||
try:
|
||||
return float(t)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def keyword_candidates(kw: str):
|
||||
out = []
|
||||
if kw:
|
||||
out.append(kw)
|
||||
cleaned = re.sub(r"(类目|市场|市场数据|市场分析|行业分析|类目分析|竞品分析|趋势分析|关键词|热词|飙升词)$", "", kw or "").strip()
|
||||
if cleaned and cleaned not in out:
|
||||
out.append(cleaned)
|
||||
alias_map = {
|
||||
"书架": ["书架", "架类书架", "置物架", "收纳架"],
|
||||
"电脑桌": ["电脑桌", "书桌", "学习桌"],
|
||||
"茶桌": ["茶桌", "茶几", "家用茶台"],
|
||||
"床垫": ["床垫", "席梦思", "乳胶床垫"],
|
||||
}
|
||||
for k, vals in alias_map.items():
|
||||
if k in (kw or ""):
|
||||
for v in vals:
|
||||
if v not in out:
|
||||
out.append(v)
|
||||
return [x for x in out if len(x) >= 2]
|
||||
|
||||
|
||||
def parse_leaf_rows(text: str):
|
||||
rows = []
|
||||
for row in ROW_RE.findall(text):
|
||||
if "查看 详情" not in row:
|
||||
continue
|
||||
m = re.match(r'^(.*?) 查看 详情 ([^ ]+) (-?\d+(?:\.\d+)?)%$', row)
|
||||
if not m:
|
||||
continue
|
||||
prefix = m.group(1).strip()
|
||||
amount_raw = m.group(2).strip()
|
||||
yoy_raw = f"{m.group(3)}%"
|
||||
toks = prefix.split()
|
||||
if len(toks) < 4:
|
||||
continue
|
||||
one, two, three = toks[0], toks[1], toks[2]
|
||||
leaf = " ".join(toks[3:])
|
||||
rows.append(
|
||||
{
|
||||
"row": row,
|
||||
"一级类目": one,
|
||||
"二级类目": two,
|
||||
"三级类目": three,
|
||||
"叶子类目": leaf,
|
||||
"销售金额": amount_raw,
|
||||
"销售金额同比": yoy_raw,
|
||||
"销售金额_num": amount_to_num(amount_raw),
|
||||
"销售金额同比_num": pct_to_num(yoy_raw),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_ranking_rows(text: str):
|
||||
rows = []
|
||||
for row in ROW_RE.findall(text):
|
||||
if not row.endswith(" 查看"):
|
||||
continue
|
||||
m = re.match(r'^(.*?) (\d{6,20}) (\S+) (\S+) 查看$', row)
|
||||
if not m:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"商品名称": m.group(1).strip(),
|
||||
"商品ID": m.group(2).strip(),
|
||||
"店铺": m.group(3).strip(),
|
||||
"品牌": m.group(4).strip(),
|
||||
"row": row,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_surging_rows(text: str):
|
||||
rows = []
|
||||
for row in ROW_RE.findall(text):
|
||||
# 商品名 店铺 连续增长月份 近6月平均单价 销售额
|
||||
m = re.match(r'^(.*?) (\S+) (\d+) ([\d,]+(?:\.\d+)?) ([\d,]+(?:\.\d+)?(?:万|亿)?)$', row)
|
||||
if not m:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"商品": m.group(1).strip(),
|
||||
"店铺": m.group(2).strip(),
|
||||
"连续增长月份": int(m.group(3)),
|
||||
"近6月平均单价": m.group(4).strip(),
|
||||
"销售金额": m.group(5).strip(),
|
||||
"销售金额_num": amount_to_num(m.group(5).strip()),
|
||||
"row": row,
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_industry(text: str):
|
||||
m = re.search(r'一级类目 销售额 销售额同比\s+([^\s]+)\s+([\d,]+(?:\.\d+)?(?:万|亿)?)\s+(-?\d+(?:\.\d+)?)%', text)
|
||||
if not m:
|
||||
return None
|
||||
return {
|
||||
"一级类目": m.group(1),
|
||||
"销售额": m.group(2),
|
||||
"销售额_num": amount_to_num(m.group(2)),
|
||||
"销售额同比": f"{m.group(3)}%",
|
||||
"销售额同比_num": float(m.group(3)),
|
||||
}
|
||||
|
||||
|
||||
leaf_rows = parse_leaf_rows(page_text.get("leaf_category", ""))
|
||||
ranking_rows = parse_ranking_rows(page_text.get("ranking_list", ""))
|
||||
surging_rows = parse_surging_rows(page_text.get("surging_rank", ""))
|
||||
industry = parse_industry(page_text.get("industry_distribution", ""))
|
||||
|
||||
cand_norm = [norm(x) for x in keyword_candidates(keyword)]
|
||||
|
||||
def row_hit(text: str):
|
||||
n = norm(text)
|
||||
return any(c in n for c in cand_norm)
|
||||
|
||||
kw_leaf_candidates = [r for r in leaf_rows if row_hit(f"{r['三级类目']} {r['叶子类目']} {r['row']}")]
|
||||
kw_leaf = kw_leaf_candidates[0] if kw_leaf_candidates else None
|
||||
|
||||
kw_rank = None
|
||||
if kw_leaf and kw_leaf in leaf_rows:
|
||||
kw_rank = leaf_rows.index(kw_leaf) + 1
|
||||
|
||||
same_secondary = []
|
||||
if kw_leaf:
|
||||
same_secondary = [r for r in leaf_rows if r["二级类目"] == kw_leaf["二级类目"]]
|
||||
|
||||
secondary_top3 = same_secondary[:3]
|
||||
secondary_growth_top3 = sorted(same_secondary, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or -999), reverse=True)[:3]
|
||||
|
||||
top10_amt = sum([r["销售金额_num"] or 0 for r in leaf_rows[:10]]) if leaf_rows else 0
|
||||
kw_share_of_total = None
|
||||
if kw_leaf and industry and industry.get("销售额_num"):
|
||||
if industry["销售额_num"] > 0 and kw_leaf.get("销售金额_num") is not None:
|
||||
kw_share_of_total = kw_leaf["销售金额_num"] / industry["销售额_num"] * 100
|
||||
|
||||
ranking_top20 = ranking_rows[:20]
|
||||
ranking_shop_counter = Counter([r["店铺"] for r in ranking_top20])
|
||||
ranking_brand_counter = Counter([r["品牌"] for r in ranking_top20])
|
||||
ranking_kw_hits = [r for r in ranking_top20 if row_hit(f"{r['商品名称']} {r['店铺']} {r['品牌']}")]
|
||||
|
||||
surging_top10 = surging_rows[:10]
|
||||
surging_kw_hits = [r for r in surging_top10 if row_hit(f"{r['商品']} {r['店铺']}")]
|
||||
|
||||
api_lines = [line.strip() for line in request_text.splitlines() if "/api/" in line]
|
||||
api_lines = api_lines[-12:]
|
||||
|
||||
coverage = {
|
||||
"leaf_category": bool(page_text.get("leaf_category", "")),
|
||||
"ranking_list": bool(page_text.get("ranking_list", "")),
|
||||
"industry_distribution": bool(page_text.get("industry_distribution", "")),
|
||||
"surging_rank": bool(page_text.get("surging_rank", "")),
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"topic": keyword,
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"scope": scope,
|
||||
"coverage": coverage,
|
||||
"industry": industry,
|
||||
"leaf": {
|
||||
"rows_count": len(leaf_rows),
|
||||
"keyword_row": kw_leaf,
|
||||
"keyword_rank": kw_rank,
|
||||
"keyword_share_of_total_pct": kw_share_of_total,
|
||||
"top10_amount_sum": top10_amt,
|
||||
"secondary_top3": secondary_top3,
|
||||
"secondary_growth_top3": secondary_growth_top3,
|
||||
"decline_top3": sorted(leaf_rows, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or 999))[:3],
|
||||
},
|
||||
"ranking": {
|
||||
"rows_count": len(ranking_rows),
|
||||
"top20_unique_shop_count": len(ranking_shop_counter),
|
||||
"top5_shops": ranking_shop_counter.most_common(5),
|
||||
"top5_brands": ranking_brand_counter.most_common(5),
|
||||
"keyword_hits": ranking_kw_hits[:5],
|
||||
},
|
||||
"surging": {
|
||||
"rows_count": len(surging_rows),
|
||||
"top10": surging_top10,
|
||||
"keyword_hits": surging_kw_hits[:5],
|
||||
},
|
||||
"evidence": {
|
||||
"pages": [
|
||||
{"name": "叶子类目分析", "source": "leaf_category.snapshot.txt", "captured": coverage["leaf_category"]},
|
||||
{"name": "品牌店铺商品热榜", "source": "ranking_list.snapshot.txt", "captured": coverage["ranking_list"]},
|
||||
{"name": "行业分布", "source": "industry_distribution.snapshot.txt", "captured": coverage["industry_distribution"]},
|
||||
{"name": "飙升品榜单", "source": "surging_rank.snapshot.txt", "captured": coverage["surging_rank"]},
|
||||
],
|
||||
"api_lines": api_lines,
|
||||
},
|
||||
}
|
||||
|
||||
(workdir / "metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def fmt_pct(v):
|
||||
if v is None:
|
||||
return "未在当前页面取到"
|
||||
return f"{v:.2f}%"
|
||||
|
||||
|
||||
def fmt_ratio(v):
|
||||
if v is None:
|
||||
return "未在当前页面取到"
|
||||
return f"{v:.2f}%"
|
||||
|
||||
|
||||
print(f"# {keyword}市场分析简报(增强版)")
|
||||
print()
|
||||
print("## 已抓取证据")
|
||||
print(f"- 时间: {metrics['generated_at']}")
|
||||
print(f"- 范围: {metrics['scope']}")
|
||||
for p in metrics["evidence"]["pages"]:
|
||||
print(f"- 页面: {p['name']} ({'已抓取' if p['captured'] else '未抓取'})")
|
||||
if industry:
|
||||
print(f"- 行业总览: 一级类目={industry['一级类目']},销售额={industry['销售额']},同比={industry['销售额同比']}")
|
||||
else:
|
||||
print("- 行业总览: 未在当前页面取到")
|
||||
if kw_leaf:
|
||||
print(f"- 关键词命中: {kw_leaf['row']}")
|
||||
print(f"- 关键词排名: 第{kw_rank}/{len(leaf_rows)}(按销售金额)")
|
||||
else:
|
||||
print("- 关键词命中: 未在当前页面取到")
|
||||
print(f"- 请求证据(API): {len(api_lines)}条(展示最近6条)")
|
||||
for line in api_lines[-6:]:
|
||||
print(f" - {line}")
|
||||
|
||||
print()
|
||||
print("## 深度分析")
|
||||
print("### 1) 规模与趋势")
|
||||
if kw_leaf:
|
||||
print(f"- {keyword}销售金额={kw_leaf['销售金额']},同比={kw_leaf['销售金额同比']}。")
|
||||
print(f"- {keyword}占一级类目销售额占比={fmt_ratio(kw_share_of_total)}。")
|
||||
if industry and kw_leaf.get("销售金额同比_num") is not None and industry.get("销售额同比_num") is not None:
|
||||
gap = kw_leaf["销售金额同比_num"] - industry["销售额同比_num"]
|
||||
direction = "跑赢" if gap >= 0 else "跑输"
|
||||
print(f"- 相对行业同比差值={gap:.2f}pct,{direction}行业大盘。")
|
||||
else:
|
||||
print("- 相对行业同比差值: 未在当前页面取到")
|
||||
else:
|
||||
print("- 关键词行未命中,暂不输出规模趋势定量结论。")
|
||||
|
||||
print("### 2) 类目结构")
|
||||
if kw_leaf and same_secondary:
|
||||
print(f"- {keyword}位于二级类目「{kw_leaf['二级类目']}」,同二级类目共抓取{len(same_secondary)}个叶子类目。")
|
||||
if secondary_top3:
|
||||
top_desc = ";".join([f"{r['叶子类目']}({r['销售金额']}, {r['销售金额同比']})" for r in secondary_top3])
|
||||
print(f"- 同二级类目销售TOP3: {top_desc}")
|
||||
if secondary_growth_top3:
|
||||
growth_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in secondary_growth_top3])
|
||||
print(f"- 同二级类目增速TOP3: {growth_desc}")
|
||||
else:
|
||||
print("- 未命中关键词二级类目,结构分析不完整。")
|
||||
|
||||
print("### 3) 竞争格局")
|
||||
if ranking_rows:
|
||||
print(f"- 热榜样本: 抓取{len(ranking_rows)}行,TOP20覆盖店铺数={metrics['ranking']['top20_unique_shop_count']}。")
|
||||
if metrics["ranking"]["top5_shops"]:
|
||||
shop_desc = ";".join([f"{k}({v})" for k, v in metrics["ranking"]["top5_shops"][:3]])
|
||||
print(f"- 高频店铺(样本): {shop_desc}")
|
||||
if metrics["ranking"]["keyword_hits"]:
|
||||
hit_desc = ";".join([f"{r['商品名称']}[{r['店铺']}]" for r in metrics["ranking"]["keyword_hits"][:3]])
|
||||
print(f"- 关键词相关商品(热榜命中): {hit_desc}")
|
||||
else:
|
||||
print("- 关键词相关商品(热榜命中): 未在当前页面取到")
|
||||
else:
|
||||
print("- 热榜数据未抓取到有效行。")
|
||||
|
||||
print("### 4) 增长机会与风险")
|
||||
if surging_rows:
|
||||
print(f"- 飙升榜样本: 抓取{len(surging_rows)}行,TOP10中关键词命中={len(surging_kw_hits)}。")
|
||||
if surging_top10:
|
||||
top_surge = ";".join([f"{r['商品']}({r['销售金额']}, 连增{r['连续增长月份']}月)" for r in surging_top10[:3]])
|
||||
print(f"- 当前飙升代表样本: {top_surge}")
|
||||
else:
|
||||
print("- 飙升榜数据未抓取到有效行。")
|
||||
|
||||
if metrics["leaf"]["decline_top3"]:
|
||||
decline_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in metrics["leaf"]["decline_top3"][:3]])
|
||||
print(f"- 同页下滑风险叶子类目TOP3: {decline_desc}")
|
||||
|
||||
print()
|
||||
print("## 可执行建议")
|
||||
if kw_leaf:
|
||||
print(f"- P1: 围绕「{kw_leaf['二级类目']}->{kw_leaf['叶子类目']}」拆分价格带与主卖点,先对齐同比下滑原因。")
|
||||
else:
|
||||
print(f"- P1: 先在叶子类目页精确命中「{keyword}」后再做定量策略。")
|
||||
print("- P1: 以热榜TOP店铺与品牌为对标,复刻高频素材结构(标题词/卖点图/价格锚点)。")
|
||||
print("- P2: 用飙升榜样本补充新品池,优先测试连续增长月份>=5的商品方向。")
|
||||
print("- P2: 对同比显著下滑类目做止损策略(预算下调+创意替换+词包重组)。")
|
||||
print("- P3: 补抓价格段布局、需求趋势、关键词布局三页后再做投放预算分配。")
|
||||
|
||||
print()
|
||||
print("## 风险与缺口")
|
||||
if not kw_leaf:
|
||||
print("- 关键词主行未命中,当前报告存在样本偏差风险。")
|
||||
if kw_leaf and kw_leaf.get("销售金额同比") == "未在当前页面取到":
|
||||
print("- 缺失关键词同比字段,无法做趋势判断。")
|
||||
print("- 当前脚本未稳定抓到销售数量/转化率字段,件数口径需在详情/字段面板补抓。")
|
||||
print("- 若页面筛选条件被改动且未点查询,指标可能失真(已要求SOP强制点查询)。")
|
||||
|
||||
print()
|
||||
print("## 下一步")
|
||||
print("- 补抓: 价格段布局 + 需求趋势分析 + 关键词布局分析(同关键词、同时间窗)。")
|
||||
print("- 追加: 近3个月滚动对比,验证当前同比结论是否持续。")
|
||||
print("- 输出: 可选生成“策略执行单”(词包/价格带/竞店清单)。")
|
||||
print()
|
||||
print(f"(结构化指标文件: {workdir / 'metrics.json'})")
|
||||
PY
|
||||
12
remote_sync/outputs/install-media-mysql-mapping.example.json
Normal file
12
remote_sync/outputs/install-media-mysql-mapping.example.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"tables": [
|
||||
{
|
||||
"schema": "your_schema",
|
||||
"table": "your_table",
|
||||
"product_cols": ["product_no"],
|
||||
"drawing_cols": ["instruction_img_path"],
|
||||
"video_cols": ["install_video_path"],
|
||||
"media_cols": []
|
||||
}
|
||||
]
|
||||
}
|
||||
3
remote_sync/outputs/install-media-mysql-mapping.json
Normal file
3
remote_sync/outputs/install-media-mysql-mapping.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"tables": []
|
||||
}
|
||||
90
remote_sync/outputs/market-analysis-page-sop.md
Normal file
90
remote_sync/outputs/market-analysis-page-sop.md
Normal file
@ -0,0 +1,90 @@
|
||||
# 市场数据页面采集 SOP(Yuce)
|
||||
|
||||
适用范围:`hq.yucekj.com` / `yucekj.com` 的市场分析任务(行业/类目/竞品/趋势)。
|
||||
|
||||
## 1. 总体硬规则
|
||||
|
||||
1. 只允许访问 `hq.yucekj.com` 与 `yucekj.com`。
|
||||
2. 先采集证据再出结论,禁止凭记忆输出数字。
|
||||
3. 每次修改筛选条件(尤其时间周期)后,必须点击一次`查询`再抓取。
|
||||
4. 结果至少包含:
|
||||
- 2 个页面快照证据
|
||||
- 1 条请求证据(`yuce-browser requests --filter yucekj.com`)
|
||||
5. 若出现验证码/二次验证,立即暂停并要求人工处理。
|
||||
|
||||
## 2. 标准执行流程(所有页面通用)
|
||||
|
||||
1. 打开站点并确认登录态。
|
||||
2. 进入目标模块页面(优先首页卡片点击,次选已知 report URL)。
|
||||
3. 设置筛选:时间周期、类目词(如“书架”)、平台/店铺等。
|
||||
4. 点击`查询`,等待表格/图表刷新。
|
||||
5. 抓取基础页面快照。
|
||||
6. 对目标行执行展开动作(`查看`/`详情`/`更多数据`等),抓取展开页快照。
|
||||
7. 导出请求证据并与快照一起入档。
|
||||
8. 按“证据 -> 结论 -> 建议 -> 缺口”输出简报。
|
||||
|
||||
## 3. 页面级采集清单(模块 + 按钮展开)
|
||||
|
||||
> 字段以页面真实表头为准;以下是最低必采维度。
|
||||
|
||||
| 模块 | 主要采集维度(最低) | 常见按钮/展开动作 | 备注 |
|
||||
|---|---|---|---|
|
||||
| 市场洞察 | 市场规模、同比/环比、趋势区间、核心类目 | 查询、切换周期 | 先取总览,再下钻类目 |
|
||||
| 叶子类目分析 | 一级/二级/三级类目、销售额、增速、占比 | `查看`、`详情` | 书架等关键词优先在该页命中 |
|
||||
| 品牌店铺商品热榜 | 品牌/店铺/商品排名、销量/销售额、价格带 | `查看`、`详情` | 热榜可做竞品对标 |
|
||||
| 飙升品榜单 | 飙升商品、增速、店铺、价格段 | `查看`、`详情`、`选择字段` | 常需先勾选字段再确定 |
|
||||
| 品牌布局分析 | 品牌覆盖、价格分布、类目布局 | 查询、明细展开 | 对比头部/腰部品牌 |
|
||||
| 竞店布局分析 | 竞店结构、渠道布局、商品结构 | 查看明细、店铺下钻 | 结合热榜看竞店策略 |
|
||||
| 价格段布局 | 价格带占比、增速、区间分布 | 区间筛选、查询 | 用于定价建议 |
|
||||
| 产品需求分析 | 需求词、需求强度、关联类目 | 查看明细、词下钻 | 与关键词布局联动 |
|
||||
| 需求趋势分析 | 时间序列趋势、峰谷变化 | 切周期、查询 | 必须标注时间范围 |
|
||||
| 关键词布局分析 | 关键词覆盖、词排名、流量/需求信号 | 查看明细、关键词下钻 | 支撑投放与标题优化 |
|
||||
|
||||
## 4. 已验证的页面入口(可直达)
|
||||
|
||||
- 叶子类目分析:
|
||||
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_TAPE_ANALYSIS&report-view=CZqcPFPRsZ`
|
||||
- 品牌店铺商品热榜:
|
||||
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_RANKING_LIST&report-view=hEq4a98B7B`
|
||||
- 市场洞察(行业分布页):
|
||||
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_INSIGHT&tab=M_TB_INDUSTRY_DISTRIBUTION&report-view=sfQrjDMn5l`
|
||||
- 飙升品榜单:
|
||||
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_SURGING_RANK&report-view=CD6nSlf6pC`
|
||||
|
||||
## 5. 按钮展开采集逻辑(强制)
|
||||
|
||||
1. **行命中规则**:先精确匹配,再做标准化匹配(去空格、全半角统一、符号清洗),最后做包含匹配。
|
||||
2. **`查看/详情`**:
|
||||
- 点击后等待页面稳定(建议 2-5 秒)
|
||||
- 立刻抓取展开页快照
|
||||
- 记录是否发生 URL 跳转(含 `jumpingFilter`)
|
||||
3. **`选择字段/更多数据`**:
|
||||
- 打开字段面板
|
||||
- 勾选任务所需字段
|
||||
- 点击`确定`
|
||||
- 再次`查询`并抓快照
|
||||
4. **分页/滚动**:首屏无目标行时,向下滚动或翻页继续命中,直到达到最大页数或命中成功。
|
||||
5. **失败回退**:
|
||||
- 先回首页重进模块一次
|
||||
- 再改用 report URL 直达一次
|
||||
- 仍失败则输出“当前账号页面未取到”并给出缺口项
|
||||
|
||||
## 6. 证据落盘规范
|
||||
|
||||
- 快照:`/tmp/market-sop/<module>.<stage>.txt`
|
||||
- 点击日志:`/tmp/market-sop/<module>.click.<action>.txt`
|
||||
- 请求日志:`/tmp/market-sop/<module>.requests.txt`
|
||||
- 每次任务至少保留:
|
||||
- 1 份基础页快照
|
||||
- 1 份展开页快照
|
||||
- 1 份请求日志
|
||||
|
||||
## 7. 输出模板(给市场分析技能)
|
||||
|
||||
1. 已抓取证据(模块/字段/值)
|
||||
2. 核心结论(逐条引用证据)
|
||||
3. 可执行建议(P1/P2/P3)
|
||||
4. 风险与缺口(明确“未在当前页面取到”)
|
||||
5. 下一步(1-3 条)
|
||||
|
||||
> 备注:市场分析结果不做来源脱敏;保留原始指标表达,禁止改写数值。
|
||||
514
remote_sync/product-retention-decision
Executable file
514
remote_sync/product-retention-decision
Executable file
@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _sql_escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def build_sql(
|
||||
product_no: str,
|
||||
province: str,
|
||||
city: str,
|
||||
district: str,
|
||||
days: int,
|
||||
warehouse_transfer_fee: float,
|
||||
depreciation_rate: float,
|
||||
limit: int,
|
||||
) -> str:
|
||||
pno = _sql_escape(product_no.strip())
|
||||
prov = _sql_escape(province.strip())
|
||||
city_v = _sql_escape((city or "").strip())
|
||||
dist_v = _sql_escape((district or "").strip())
|
||||
wh_fee = f"{warehouse_transfer_fee:.2f}"
|
||||
dep_rate = f"{depreciation_rate:.4f}"
|
||||
return f"""
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @product_no nvarchar(120) = N'{pno}';
|
||||
DECLARE @province nvarchar(50) = N'{prov}';
|
||||
DECLARE @city nvarchar(50) = N'{city_v}';
|
||||
DECLARE @district nvarchar(50) = N'{dist_v}';
|
||||
DECLARE @days int = {days};
|
||||
DECLARE @limit int = {limit};
|
||||
DECLARE @warehouse_transfer_fee decimal(18,2) = {wh_fee};
|
||||
DECLARE @depreciation_rate decimal(10,4) = {dep_rate};
|
||||
DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE());
|
||||
DECLARE @end_time datetime2 = GETDATE();
|
||||
|
||||
DECLARE @province_norm nvarchar(50) =
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@province, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
|
||||
DECLARE @city_norm nvarchar(50) =
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@city, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
|
||||
DECLARE @district_norm nvarchar(50) =
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@district, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_pool') IS NOT NULL
|
||||
DROP TABLE #sku_pool;
|
||||
|
||||
SELECT DISTINCT
|
||||
s.sku_id,
|
||||
s.name AS sku_name,
|
||||
s.other_7 AS product_no,
|
||||
s.i_id AS style_code,
|
||||
s.other_1 AS spu
|
||||
INTO #sku_pool
|
||||
FROM dbo.jst_sku AS s WITH (NOLOCK)
|
||||
WHERE ISNULL(NULLIF(s.sku_id, ''), '') <> ''
|
||||
AND (
|
||||
NULLIF(s.other_7, '') = @product_no
|
||||
OR NULLIF(s.i_id, '') = @product_no
|
||||
OR NULLIF(s.other_1, '') = @product_no
|
||||
OR NULLIF(s.sku_id, '') = @product_no
|
||||
OR NULLIF(s.other_7, '') LIKE @product_no + '%'
|
||||
);
|
||||
|
||||
INSERT INTO #sku_pool (sku_id, sku_name, product_no, style_code, spu)
|
||||
SELECT DISTINCT
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined36, '') AS sku_name,
|
||||
NULLIF(a.undefined96, '') AS product_no,
|
||||
NULL,
|
||||
NULL
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
LEFT JOIN #sku_pool AS p
|
||||
ON p.sku_id = NULLIF(a.undefined10, '')
|
||||
WHERE p.sku_id IS NULL
|
||||
AND ISNULL(NULLIF(a.undefined10, ''), '') <> ''
|
||||
AND (
|
||||
NULLIF(a.undefined96, '') = @product_no
|
||||
OR NULLIF(a.undefined96, '') LIKE @product_no + '%'
|
||||
);
|
||||
|
||||
IF OBJECT_ID('tempdb..#order_base') IS NOT NULL
|
||||
DROP TABLE #order_base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), @product_no) AS product_no,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
ISNULL(NULLIF(a.undefined73, ''), ISNULL(NULLIF(e.logistic_company_name, ''), '未标注')) AS express_company,
|
||||
COALESCE(NULLIF(a.undefined66, ''), NULLIF(s.other_3, ''), '未标注') AS ship_warehouse_name,
|
||||
ISNULL(NULLIF(a.undefined74, ''), '') AS province_raw,
|
||||
ISNULL(NULLIF(a.undefined75, ''), '') AS city_raw,
|
||||
ISNULL(NULLIF(e.receiver_address, ''), '') AS receiver_address,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined74, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS province_norm,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined75, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS city_norm,
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(e.receiver_address, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS receiver_address_norm,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS sale_amount,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS cost_amount,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
)
|
||||
+ COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS return_qty,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined90, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS return_freight_fee,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined79, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS warehouse_transfer_fee_obs
|
||||
INTO #order_base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
INNER JOIN #sku_pool AS p
|
||||
ON p.sku_id = NULLIF(a.undefined10, '')
|
||||
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
|
||||
ON s.sku_id = NULLIF(a.undefined10, '')
|
||||
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
|
||||
ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '')
|
||||
WHERE ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
|
||||
AND a.createdAt >= DATEADD(day, -(@days + 7), GETDATE());
|
||||
|
||||
IF OBJECT_ID('tempdb..#region_line') IS NOT NULL
|
||||
DROP TABLE #region_line;
|
||||
|
||||
SELECT
|
||||
ob.*
|
||||
INTO #region_line
|
||||
FROM #order_base AS ob
|
||||
WHERE ob.ship_dt >= @start_time
|
||||
AND ob.ship_dt < @end_time
|
||||
AND (
|
||||
(@province_norm = '' AND ISNULL(NULLIF(ob.province_norm, ''), '') <> '')
|
||||
OR ob.province_norm LIKE '%' + @province_norm + '%'
|
||||
OR @province_norm LIKE '%' + ob.province_norm + '%'
|
||||
OR ob.receiver_address_norm LIKE '%' + @province_norm + '%'
|
||||
)
|
||||
AND (
|
||||
@city_norm = ''
|
||||
OR ob.city_norm LIKE '%' + @city_norm + '%'
|
||||
OR @city_norm LIKE '%' + ob.city_norm + '%'
|
||||
OR ob.receiver_address_norm LIKE '%' + @city_norm + '%'
|
||||
)
|
||||
AND (
|
||||
@district_norm = ''
|
||||
OR ob.receiver_address LIKE '%' + @district + '%'
|
||||
OR ob.receiver_address_norm LIKE '%' + @district_norm + '%'
|
||||
);
|
||||
|
||||
IF OBJECT_ID('tempdb..#order_agg') IS NOT NULL
|
||||
DROP TABLE #order_agg;
|
||||
|
||||
SELECT
|
||||
rl.order_id,
|
||||
MAX(rl.shop_name) AS shop_name,
|
||||
MAX(rl.product_no) AS product_no,
|
||||
MAX(rl.ship_dt) AS ship_dt,
|
||||
SUM(ISNULL(rl.sale_amount, 0)) AS sale_amount_order,
|
||||
SUM(ISNULL(rl.cost_amount, 0)) AS cost_amount_order,
|
||||
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_order,
|
||||
SUM(ISNULL(rl.return_qty, 0)) AS return_qty_order,
|
||||
SUM(ISNULL(rl.return_freight_fee, 0)) AS return_freight_fee_order,
|
||||
SUM(ISNULL(rl.warehouse_transfer_fee_obs, 0)) AS warehouse_transfer_fee_order
|
||||
INTO #order_agg
|
||||
FROM #region_line AS rl
|
||||
GROUP BY rl.order_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#freight_order') IS NOT NULL
|
||||
DROP TABLE #freight_order;
|
||||
|
||||
SELECT
|
||||
NULLIF(f.so_id, '') AS order_id,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), f.freightCost), 0)) AS outbound_freight_order
|
||||
INTO #freight_order
|
||||
FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK)
|
||||
INNER JOIN #region_line AS rl
|
||||
ON rl.order_id = NULLIF(f.so_id, '')
|
||||
AND rl.sku_id = NULLIF(f.sku_id, '')
|
||||
WHERE f.send_date >= @start_time
|
||||
AND f.send_date < @end_time
|
||||
GROUP BY NULLIF(f.so_id, '');
|
||||
|
||||
IF OBJECT_ID('tempdb..#order_fact') IS NOT NULL
|
||||
DROP TABLE #order_fact;
|
||||
|
||||
SELECT
|
||||
oa.order_id,
|
||||
oa.shop_name,
|
||||
oa.product_no,
|
||||
oa.ship_dt,
|
||||
oa.sale_amount_order,
|
||||
oa.cost_amount_order,
|
||||
oa.shipped_qty_order,
|
||||
oa.return_qty_order,
|
||||
oa.return_freight_fee_order,
|
||||
oa.warehouse_transfer_fee_order,
|
||||
COALESCE(fo.outbound_freight_order, 0) AS outbound_freight_order
|
||||
INTO #order_fact
|
||||
FROM #order_agg AS oa
|
||||
LEFT JOIN #freight_order AS fo
|
||||
ON fo.order_id = oa.order_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#kpi') IS NOT NULL
|
||||
DROP TABLE #kpi;
|
||||
|
||||
SELECT
|
||||
COUNT(*) AS sample_order_count,
|
||||
SUM(ISNULL(ofa.shipped_qty_order, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(ofa.return_qty_order, 0)) AS return_qty_sum,
|
||||
SUM(ISNULL(ofa.sale_amount_order, 0)) AS sale_amount_sum,
|
||||
SUM(ISNULL(ofa.cost_amount_order, 0)) AS cost_amount_sum,
|
||||
AVG(NULLIF(ISNULL(ofa.outbound_freight_order, 0), 0)) AS avg_outbound_freight_nonzero,
|
||||
AVG(ISNULL(ofa.outbound_freight_order, 0)) AS avg_outbound_freight_per_order,
|
||||
AVG(NULLIF(ISNULL(ofa.return_freight_fee_order, 0), 0)) AS avg_return_freight_observed,
|
||||
AVG(NULLIF(ISNULL(ofa.warehouse_transfer_fee_order, 0), 0)) AS avg_warehouse_transfer_observed,
|
||||
MIN(ofa.ship_dt) AS first_ship_time,
|
||||
MAX(ofa.ship_dt) AS last_ship_time
|
||||
INTO #kpi
|
||||
FROM #order_fact AS ofa;
|
||||
|
||||
-- 1) 样本概览
|
||||
SELECT
|
||||
@product_no AS input_product_no,
|
||||
@province AS input_province,
|
||||
@city AS input_city,
|
||||
@district AS input_district,
|
||||
@days AS lookback_days,
|
||||
(SELECT COUNT(*) FROM #sku_pool) AS matched_sku_count,
|
||||
k.sample_order_count,
|
||||
k.shipped_qty_sum,
|
||||
k.return_qty_sum,
|
||||
CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN NULL ELSE CAST(k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) AS decimal(10,4)) END AS return_rate,
|
||||
k.first_ship_time,
|
||||
k.last_ship_time
|
||||
FROM #kpi AS k;
|
||||
|
||||
-- 2) 利润估算因子(按订单均值)
|
||||
SELECT
|
||||
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale_amount_per_order,
|
||||
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost_amount_per_order,
|
||||
CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS est_outbound_freight_per_order_15d,
|
||||
CAST(
|
||||
COALESCE(
|
||||
k.avg_return_freight_observed,
|
||||
ISNULL(k.avg_outbound_freight_per_order, 0)
|
||||
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
|
||||
0
|
||||
) AS decimal(18,2)
|
||||
) AS est_return_freight_per_order_15d,
|
||||
CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer_fee_used,
|
||||
CAST(
|
||||
CASE WHEN k.sample_order_count = 0 THEN NULL ELSE
|
||||
(k.sale_amount_sum / NULLIF(k.sample_order_count, 0))
|
||||
- (k.cost_amount_sum / NULLIF(k.sample_order_count, 0))
|
||||
- ISNULL(k.avg_outbound_freight_per_order, 0)
|
||||
- COALESCE(
|
||||
k.avg_return_freight_observed,
|
||||
ISNULL(k.avg_outbound_freight_per_order, 0)
|
||||
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
|
||||
0
|
||||
)
|
||||
- COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0)
|
||||
END AS decimal(18,2)
|
||||
) AS est_profit_per_order,
|
||||
CAST(
|
||||
CASE WHEN k.sample_order_count = 0 THEN NULL
|
||||
WHEN (k.sale_amount_sum / NULLIF(k.sample_order_count, 0)) = 0 THEN NULL
|
||||
ELSE (
|
||||
(
|
||||
(k.sale_amount_sum / NULLIF(k.sample_order_count, 0))
|
||||
- (k.cost_amount_sum / NULLIF(k.sample_order_count, 0))
|
||||
- ISNULL(k.avg_outbound_freight_per_order, 0)
|
||||
- COALESCE(
|
||||
k.avg_return_freight_observed,
|
||||
ISNULL(k.avg_outbound_freight_per_order, 0)
|
||||
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
|
||||
0
|
||||
)
|
||||
- COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0)
|
||||
)
|
||||
/ NULLIF((k.sale_amount_sum / NULLIF(k.sample_order_count, 0)), 0)
|
||||
)
|
||||
END AS decimal(10,4)
|
||||
) AS est_profit_margin
|
||||
FROM #kpi AS k;
|
||||
|
||||
-- 3) 处置建议(退货/补偿/放货,含退回折旧损失)
|
||||
WITH decision_base AS (
|
||||
SELECT
|
||||
k.sample_order_count,
|
||||
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale,
|
||||
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost,
|
||||
CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS avg_outbound,
|
||||
CAST(COALESCE(
|
||||
k.avg_return_freight_observed,
|
||||
ISNULL(k.avg_outbound_freight_per_order, 0) * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
|
||||
0
|
||||
) AS decimal(18,2)) AS est_return_freight,
|
||||
CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer
|
||||
FROM #kpi AS k
|
||||
),
|
||||
calc AS (
|
||||
SELECT
|
||||
db.*,
|
||||
CAST(ISNULL(db.avg_cost, 0) * @depreciation_rate AS decimal(18,2)) AS depreciation_loss_if_return,
|
||||
CAST((ISNULL(db.avg_outbound, 0) + ISNULL(db.est_return_freight, 0) + ISNULL(db.warehouse_transfer, 0) + (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS expected_loss_if_return,
|
||||
CAST((ISNULL(db.avg_sale, 0) - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS keep_order_net_before_comp,
|
||||
CAST((0 - ISNULL(db.avg_outbound, 0) - ISNULL(db.est_return_freight, 0) - ISNULL(db.warehouse_transfer, 0) - (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS option_return_net_profit,
|
||||
CAST((0 - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS option_release_net_profit
|
||||
FROM decision_base AS db
|
||||
),
|
||||
range_calc AS (
|
||||
SELECT
|
||||
c.*,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN c.sample_order_count = 0 THEN 0
|
||||
WHEN c.keep_order_net_before_comp <= 0 THEN 0
|
||||
ELSE
|
||||
CASE
|
||||
WHEN (c.keep_order_net_before_comp * 0.70) < (c.expected_loss_if_return * 0.90) THEN (c.keep_order_net_before_comp * 0.70)
|
||||
ELSE (c.expected_loss_if_return * 0.90)
|
||||
END
|
||||
END
|
||||
AS decimal(18,2)
|
||||
) AS comp_high_raw
|
||||
FROM calc AS c
|
||||
)
|
||||
SELECT
|
||||
rc.sample_order_count,
|
||||
rc.keep_order_net_before_comp AS est_profit_per_order,
|
||||
CAST(@depreciation_rate AS decimal(10,4)) AS depreciation_rate_used,
|
||||
rc.depreciation_loss_if_return,
|
||||
rc.expected_loss_if_return,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN rc.sample_order_count = 0 THEN 0
|
||||
WHEN rc.comp_high_raw <= 0 THEN 0
|
||||
ELSE rc.comp_high_raw * 0.50
|
||||
END AS decimal(18,2)
|
||||
) AS compensation_low,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN rc.sample_order_count = 0 THEN 0
|
||||
WHEN rc.comp_high_raw <= 0 THEN 0
|
||||
ELSE rc.comp_high_raw
|
||||
END AS decimal(18,2)
|
||||
) AS compensation_high,
|
||||
rc.option_return_net_profit,
|
||||
rc.option_release_net_profit,
|
||||
CAST(
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.50 END)
|
||||
AS decimal(18,2)
|
||||
) AS option_discount_net_profit_low,
|
||||
CAST(
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
|
||||
AS decimal(18,2)
|
||||
) AS option_discount_net_profit_high,
|
||||
CASE
|
||||
WHEN rc.sample_order_count = 0 THEN N'样本不足'
|
||||
WHEN (
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
|
||||
) >= rc.option_return_net_profit
|
||||
AND (
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
|
||||
) >= rc.option_release_net_profit
|
||||
THEN N'优惠留客'
|
||||
WHEN rc.option_return_net_profit >= rc.option_release_net_profit THEN N'退回'
|
||||
ELSE N'放货'
|
||||
END AS recommended_option,
|
||||
CASE
|
||||
WHEN rc.sample_order_count = 0 THEN N'样本不足,暂不做退补决策(建议放宽省市区或延长到30天)。'
|
||||
WHEN (
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
|
||||
) >= rc.option_return_net_profit
|
||||
AND (
|
||||
rc.keep_order_net_before_comp
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
|
||||
) >= rc.option_release_net_profit
|
||||
THEN N'建议优先优惠留客:在建议补偿区间内,净收益优于退回和放货。'
|
||||
WHEN rc.option_return_net_profit >= rc.option_release_net_profit
|
||||
THEN N'建议优先退回:按当前折旧率和运费估算,退回净损失低于放货。'
|
||||
ELSE N'建议优先放货:退回综合损失(含20%折旧)高于放货损失。'
|
||||
END AS recommended_action,
|
||||
CASE
|
||||
WHEN rc.sample_order_count >= 30 THEN 'high'
|
||||
WHEN rc.sample_order_count >= 10 THEN 'medium'
|
||||
WHEN rc.sample_order_count > 0 THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS confidence_level,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN rc.sample_order_count = 0 THEN NULL
|
||||
ELSE rc.expected_loss_if_return
|
||||
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.75 END)
|
||||
END AS decimal(18,2)
|
||||
) AS est_saving_vs_return
|
||||
FROM range_calc AS rc;
|
||||
|
||||
-- 4) 快递/仓库策略(近15天样本)
|
||||
SELECT TOP (@limit)
|
||||
rl.express_company,
|
||||
COUNT(DISTINCT rl.order_id) AS order_count,
|
||||
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
CAST(
|
||||
SUM(ISNULL(rl.shipped_qty, 0))
|
||||
/ NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0)
|
||||
AS decimal(10,4)
|
||||
) AS shipped_qty_ratio
|
||||
FROM #region_line AS rl
|
||||
GROUP BY rl.express_company
|
||||
ORDER BY shipped_qty_sum DESC, order_count DESC;
|
||||
|
||||
SELECT TOP (@limit)
|
||||
rl.ship_warehouse_name,
|
||||
COUNT(DISTINCT rl.order_id) AS order_count,
|
||||
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum,
|
||||
CAST(
|
||||
SUM(ISNULL(rl.shipped_qty, 0))
|
||||
/ NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0)
|
||||
AS decimal(10,4)
|
||||
) AS shipped_qty_ratio
|
||||
FROM #region_line AS rl
|
||||
GROUP BY rl.ship_warehouse_name
|
||||
ORDER BY shipped_qty_sum DESC, order_count DESC;
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Estimate profit-based aftersale decision (退回/优惠/放货) by product_no + region, including return depreciation loss"
|
||||
)
|
||||
parser.add_argument("--product-no", required=True, help="商品货号(product_no/style_code/spu/SKU)")
|
||||
parser.add_argument("--province", required=True, help="省份")
|
||||
parser.add_argument("--city", default="", help="城市(可选)")
|
||||
parser.add_argument("--district", default="", help="区县(可选)")
|
||||
parser.add_argument("--days", type=int, default=15, help="lookback window, default 15")
|
||||
parser.add_argument("--warehouse-transfer-fee", type=float, default=0.0, help="仓储转运费兜底值(当样本无观测值时使用)")
|
||||
parser.add_argument("--depreciation-rate", type=float, default=0.20, help="退回折旧损失率,默认0.20(20%%)")
|
||||
parser.add_argument("--limit", type=int, default=10, help="max rows for strategy result sets")
|
||||
args = parser.parse_args()
|
||||
|
||||
product_no = args.product_no.strip()
|
||||
province = args.province.strip()
|
||||
if not product_no:
|
||||
print("--product-no is required", file=sys.stderr)
|
||||
return 2
|
||||
if not province:
|
||||
print("--province is required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
days = max(7, min(args.days, 90))
|
||||
limit = max(1, min(args.limit, 100))
|
||||
depreciation_rate = max(0.0, min(args.depreciation_rate, 1.0))
|
||||
|
||||
sql = build_sql(
|
||||
product_no=product_no,
|
||||
province=province,
|
||||
city=args.city,
|
||||
district=args.district,
|
||||
days=days,
|
||||
warehouse_transfer_fee=args.warehouse_transfer_fee,
|
||||
depreciation_rate=depreciation_rate,
|
||||
limit=limit,
|
||||
)
|
||||
cmd = ["company-db", "query", "--sql", sql]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "company-db failed").strip()
|
||||
print(err, file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
print(proc.stdout.rstrip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
63
remote_sync/product-retention-decision.SKILL.md
Normal file
63
remote_sync/product-retention-decision.SKILL.md
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
name: product-retention-decision
|
||||
description: Evaluate profit-based aftersale handling by product number and region, include return depreciation loss (default 20%), and recommend among return vs discount-compensation vs release-goods.
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "💰", "os": ["linux"], "requires": { "bins": ["company-db", "product-retention-decision"] } } }
|
||||
---
|
||||
|
||||
# product-retention-decision Skill (OpenClaw)
|
||||
|
||||
Use this skill when user asks:
|
||||
- 货号 + 省市区,判断退货还是补偿更划算
|
||||
- 计算该订单利润后给出补偿区间
|
||||
- 希望尽量留客、降低售后成本
|
||||
- 要求把退回折旧损失(如20%)纳入决策
|
||||
- 要求比较“退回/优惠/放货”三种方案
|
||||
|
||||
## Required inputs
|
||||
|
||||
- `product_no` (货号/商品ID/SPU/SKU)
|
||||
- `province`
|
||||
- Optional: `city`, `district`
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
product-retention-decision --product-no "<货号>" --province "<省>" [--city "<市>"] [--district "<区县>"] --days 15 --warehouse-transfer-fee 0 --depreciation-rate 0.20 --limit 10
|
||||
```
|
||||
|
||||
## Cost model (15 days)
|
||||
|
||||
估算按订单均值:
|
||||
|
||||
- 销售价格(avg_sale_amount_per_order)
|
||||
- 产品成本(avg_cost_amount_per_order)
|
||||
- 发货运费(est_outbound_freight_per_order_15d)
|
||||
- 退货运费(est_return_freight_per_order_15d)
|
||||
- 仓储转运费(warehouse_transfer_fee_used)
|
||||
- 退回折旧损失(depreciation_loss_if_return = 产品成本 * depreciation_rate)
|
||||
|
||||
公式:
|
||||
|
||||
`est_profit_per_order = 销售 - 成本 - 发货运费 - 退货运费 - 仓储转运费`
|
||||
|
||||
三方案净收益比较:
|
||||
|
||||
- `option_return_net_profit = -(发货运费 + 退货运费 + 仓储转运费 + 退回折旧损失)`
|
||||
- `option_discount_net_profit_{low/high} = (销售 - 成本 - 发货运费) - 补偿金额`
|
||||
- `option_release_net_profit = -(产品成本 + 发货运费)` (放货:仅退款不退货)
|
||||
|
||||
## Output result sets
|
||||
|
||||
1. 样本概览(订单数、发货件数、退货率)
|
||||
2. 利润估算因子(5项成本+利润)
|
||||
3. 处置建议(退回/优惠/放货三方案净收益、补偿区间、推荐方案、置信度)
|
||||
4. 快递策略TOP(近15天)
|
||||
5. 仓库策略TOP(近15天)
|
||||
|
||||
## Execution rules
|
||||
|
||||
1. Always run `exec: company-db health` once per turn before this command.
|
||||
2. Piece/order counts first; amount as model inputs.
|
||||
3. If no sample, return clear fallback (放宽地区条件或延长周期) and do not fake compensation.
|
||||
4. This route must stay in SQL only.
|
||||
19
remote_sync/tmp_probe_mysql.py
Normal file
19
remote_sync/tmp_probe_mysql.py
Normal file
@ -0,0 +1,19 @@
|
||||
import pymysql
|
||||
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
|
||||
cur=conn.cursor()
|
||||
cases = [
|
||||
("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG003919%",), "sheet1 like DSG003919"),
|
||||
("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG0039%",), "sheet1 like DSG0039"),
|
||||
("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG003919%",), "missiontask skucodes like DSG003919"),
|
||||
("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG0039%",), "missiontask skucodes like DSG0039"),
|
||||
("select id,hid,url,orderNum from missiontask_instructions where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "instructions via missiontask"),
|
||||
("select id,hid,url,orderNum from missiontask_video where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "video via missiontask"),
|
||||
]
|
||||
for sql, params, title in cases:
|
||||
print(f"\n=== {title} ===")
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
print('rows', len(rows))
|
||||
for r in rows[:10]:
|
||||
print(r)
|
||||
cur.close(); conn.close()
|
||||
14
remote_sync/tmp_probe_mysql2.py
Normal file
14
remote_sync/tmp_probe_mysql2.py
Normal file
@ -0,0 +1,14 @@
|
||||
import pymysql
|
||||
hid='26de2902-8c28-4ba9-97d4-167609043fcc'
|
||||
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
|
||||
cur=conn.cursor()
|
||||
cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 20',(hid,))
|
||||
print('instructions',len(cur.fetchall()))
|
||||
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 20',(hid,))
|
||||
rows=cur.fetchall()
|
||||
print('video',len(rows))
|
||||
for r in rows[:5]:
|
||||
print(r)
|
||||
cur.execute('select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where id=%s',(hid,))
|
||||
print('task',cur.fetchone())
|
||||
cur.close();conn.close()
|
||||
15
remote_sync/tmp_probe_mysql3.py
Normal file
15
remote_sync/tmp_probe_mysql3.py
Normal file
@ -0,0 +1,15 @@
|
||||
import pymysql
|
||||
hid='26de2902-8c28-4ba9-97d4-167609043fcc'
|
||||
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
|
||||
cur=conn.cursor()
|
||||
cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 10',(hid,))
|
||||
rows=cur.fetchall()
|
||||
print('instructions',len(rows))
|
||||
for r in rows:
|
||||
print(r)
|
||||
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 10',(hid,))
|
||||
rows=cur.fetchall()
|
||||
print('video',len(rows))
|
||||
for r in rows:
|
||||
print(r)
|
||||
cur.close();conn.close()
|
||||
13
remote_sync/tmp_probe_mysql4.py
Normal file
13
remote_sync/tmp_probe_mysql4.py
Normal file
@ -0,0 +1,13 @@
|
||||
import pymysql
|
||||
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
|
||||
cur=conn.cursor()
|
||||
cur.execute("select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where withVideo='1' and isDelete='0' order by createdTime desc limit 5")
|
||||
rows=cur.fetchall()
|
||||
print('tasks',len(rows))
|
||||
for r in rows:
|
||||
print('task',r[0],'picid',r[2],'withVideo',r[5])
|
||||
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 5',(r[0],))
|
||||
v=cur.fetchall(); print(' video_rows',len(v));
|
||||
for x in v:
|
||||
print(' ',x)
|
||||
cur.close(); conn.close()
|
||||
131
remote_sync/web-task-router.SKILL.md
Normal file
131
remote_sync/web-task-router.SKILL.md
Normal file
@ -0,0 +1,131 @@
|
||||
---
|
||||
name: web-task-router
|
||||
description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks.
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } }
|
||||
---
|
||||
|
||||
# web-task-router Skill (OpenClaw)
|
||||
|
||||
Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification.
|
||||
|
||||
## Registry file
|
||||
|
||||
- Read: `/root/.openclaw/workspace/websites.yaml`
|
||||
- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`.
|
||||
|
||||
## Decision flow
|
||||
|
||||
1. Parse user intent (what data/action is needed).
|
||||
2. Check `routing_rules.hard_route_rules` first.
|
||||
3. If user text matches any `when_keywords`, force `force_site_id`.
|
||||
4. If no hard rule matched, choose one best site from `purposes/keywords`.
|
||||
5. If no reliable match, ask one short clarification question.
|
||||
6. If matched site has `login_required: false`, open target directly and proceed.
|
||||
7. If `login_required: true`, resolve credentials, login, then execute task.
|
||||
|
||||
## Hard rule for market analysis
|
||||
|
||||
- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`.
|
||||
- In this case, never use `web_search` or `web_fetch`.
|
||||
- `browser` tool is disabled for market analysis.
|
||||
- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`.
|
||||
- Page-level extraction and button-expansion SOP:
|
||||
- `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`
|
||||
- Do not output market-analysis metrics unless collected from yucekj pages in this run.
|
||||
- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause.
|
||||
|
||||
## Hard rule for company sales / aftersale analysis
|
||||
|
||||
- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比"), always route to `company-sales-db-analysis`.
|
||||
- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks.
|
||||
- Preferred execution path:
|
||||
1) `exec`: `company-db health`
|
||||
2) `exec`: `company-db tables`
|
||||
3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query)
|
||||
- Before writing analysis, first read field catalog:
|
||||
- `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
|
||||
- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up.
|
||||
|
||||
## Hard rule for install drawing/video lookup
|
||||
|
||||
- If user request mentions install media intent (for example: "安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), always route to `install-media-db-analysis`.
|
||||
- Preferred execution path:
|
||||
1) `exec`: `install-media-lookup --order-id "<订单号>"`
|
||||
2) if user gives product number directly: `exec`: `install-media-lookup --product-no "<货号>"`
|
||||
- Keep two fields in final answer:
|
||||
- `raw_path`
|
||||
- `media_url` (browser URL after path conversion)
|
||||
- URL conversion must follow:
|
||||
- `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`
|
||||
- If no data or MySQL connection fails, return explicit failure reason and the next actionable check. Do not fabricate links.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables.
|
||||
2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`.
|
||||
3. If still unresolved, ask user for manual input.
|
||||
|
||||
## Login/verification policy
|
||||
|
||||
- Do not print raw password in chat.
|
||||
- If OTP/CAPTCHA appears, pause and ask user for input.
|
||||
- After user replies with code (or says challenge is done), resume from paused step.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Operate only inside `allowed_domains`.
|
||||
- If redirected outside allowed domains, stop and ask user.
|
||||
- Keep actions non-destructive unless user explicitly requests changes.
|
||||
- Summarize final result briefly with data outcome and status.
|
||||
|
||||
## Autonomous module selection (yuce market-analysis)
|
||||
|
||||
Use this deterministic map before clicking pages:
|
||||
|
||||
- market size / growth / trend -> 市场洞察
|
||||
- category share / fine-grained opportunities -> 叶子类目分析
|
||||
- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析
|
||||
- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析
|
||||
- price strategy / price band -> 价格段布局, 品牌布局分析
|
||||
|
||||
Execution requirements:
|
||||
|
||||
1. Build a mini plan: requested metrics -> target modules (inside yuce only).
|
||||
2. Open/login yuce, navigate by module names, capture snapshots as evidence.
|
||||
3. If any metric lacks visible data, return "not available in current view" instead of guessing.
|
||||
4. Never use `cron` for market-analysis tasks.
|
||||
5. Never output final numbers before evidence capture.
|
||||
|
||||
### Strict module whitelist
|
||||
|
||||
Only use these exact module names in planning or reporting:
|
||||
市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析
|
||||
|
||||
If model generates non-whitelist names, discard and regenerate with whitelist names only.
|
||||
|
||||
## Analysis brief workflow (mandatory for market tasks)
|
||||
|
||||
When user asks for market analysis report/brief:
|
||||
|
||||
1. Route to yuce modules by requested metrics.
|
||||
2. Collect evidence first:
|
||||
- `yuce-browser open https://hq.yucekj.com`
|
||||
- `yuce-browser snapshot <limit>` on at least 2 relevant views
|
||||
- `yuce-browser requests --filter yucekj.com`
|
||||
- If any filter/date changed, click `查询` first (mandatory in SOP)
|
||||
3. Build brief with fixed sections:
|
||||
- 已抓取证据
|
||||
- 核心结论
|
||||
- 可执行建议
|
||||
- 风险与缺口
|
||||
- 下一步
|
||||
4. Mark inferred statements as `推断`, never present as hard fact.
|
||||
5. If evidence is incomplete, stop at gap list and ask user one precise follow-up.
|
||||
|
||||
### Deterministic brief command
|
||||
|
||||
For requests like "分析简报/结论+建议", run:
|
||||
- `market-brief-report <keyword>`
|
||||
|
||||
Use command output as primary evidence text. Do not rewrite numeric values.
|
||||
119
remote_sync/web-task-router.remote.SKILL.md
Normal file
119
remote_sync/web-task-router.remote.SKILL.md
Normal file
@ -0,0 +1,119 @@
|
||||
---
|
||||
name: web-task-router
|
||||
description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks.
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } }
|
||||
---
|
||||
|
||||
# web-task-router Skill (OpenClaw)
|
||||
|
||||
Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification.
|
||||
|
||||
## Registry file
|
||||
|
||||
- Read: `/root/.openclaw/workspace/websites.yaml`
|
||||
- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`.
|
||||
|
||||
## Decision flow
|
||||
|
||||
1. Parse user intent (what data/action is needed).
|
||||
2. Check `routing_rules.hard_route_rules` first.
|
||||
3. If user text matches any `when_keywords`, force `force_site_id`.
|
||||
4. If no hard rule matched, choose one best site from `purposes/keywords`.
|
||||
5. If no reliable match, ask one short clarification question.
|
||||
6. If matched site has `login_required: false`, open target directly and proceed.
|
||||
7. If `login_required: true`, resolve credentials, login, then execute task.
|
||||
|
||||
## Hard rule for market analysis
|
||||
|
||||
- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`.
|
||||
- In this case, never use `web_search` or `web_fetch`.
|
||||
- `browser` tool is disabled for market analysis.
|
||||
- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`.
|
||||
- Do not output market-analysis metrics unless collected from yucekj pages in this run.
|
||||
- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause.
|
||||
|
||||
## Hard rule for company sales / aftersale analysis
|
||||
|
||||
- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"), always route to `company-sales-db-analysis`.
|
||||
- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks.
|
||||
- Preferred execution path:
|
||||
1) `exec`: `company-db health`
|
||||
2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
3) Product-ID SKU list query: `exec`: `product-skus-by-id "<商品id>" --limit 1000`
|
||||
4) Sales/aftersale analysis: `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query)
|
||||
5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
|
||||
- For customer-service analysis outputs, include at least 2 dialogue snippets and recommended reply wording.
|
||||
- For customer-service analysis outputs, include capability scoring by agent name (4 dimensions + overall grade).
|
||||
- Before writing analysis, first read field catalog:
|
||||
- `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
|
||||
- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables.
|
||||
2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`.
|
||||
3. If still unresolved, ask user for manual input.
|
||||
|
||||
## Login/verification policy
|
||||
|
||||
- Do not print raw password in chat.
|
||||
- If OTP/CAPTCHA appears, pause and ask user for input.
|
||||
- After user replies with code (or says challenge is done), resume from paused step.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Operate only inside `allowed_domains`.
|
||||
- If redirected outside allowed domains, stop and ask user.
|
||||
- Keep actions non-destructive unless user explicitly requests changes.
|
||||
- Summarize final result briefly with data outcome and status.
|
||||
|
||||
## Autonomous module selection (yuce market-analysis)
|
||||
|
||||
Use this deterministic map before clicking pages:
|
||||
|
||||
- market size / growth / trend -> 市场洞察
|
||||
- category share / fine-grained opportunities -> 叶子类目分析
|
||||
- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析
|
||||
- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析
|
||||
- price strategy / price band -> 价格段布局, 品牌布局分析
|
||||
|
||||
Execution requirements:
|
||||
|
||||
1. Build a mini plan: requested metrics -> target modules (inside yuce only).
|
||||
2. Open/login yuce, navigate by module names, capture snapshots as evidence.
|
||||
3. If any metric lacks visible data, return "not available in current view" instead of guessing.
|
||||
4. Never use `cron` for market-analysis tasks.
|
||||
5. Never output final numbers before evidence capture.
|
||||
|
||||
### Strict module whitelist
|
||||
|
||||
Only use these exact module names in planning or reporting:
|
||||
市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析
|
||||
|
||||
If model generates non-whitelist names, discard and regenerate with whitelist names only.
|
||||
|
||||
## Analysis brief workflow (mandatory for market tasks)
|
||||
|
||||
When user asks for market analysis report/brief:
|
||||
|
||||
1. Route to yuce modules by requested metrics.
|
||||
2. Collect evidence first:
|
||||
- `yuce-browser open https://hq.yucekj.com`
|
||||
- `yuce-browser snapshot <limit>` on at least 2 relevant views
|
||||
- `yuce-browser requests --filter yucekj.com`
|
||||
3. Build brief with fixed sections:
|
||||
- 已抓取证据
|
||||
- 核心结论
|
||||
- 可执行建议
|
||||
- 风险与缺口
|
||||
- 下一步
|
||||
4. Mark inferred statements as `推断`, never present as hard fact.
|
||||
5. If evidence is incomplete, stop at gap list and ask user one precise follow-up.
|
||||
|
||||
### Deterministic brief command
|
||||
|
||||
For requests like "分析简报/结论+建议", run:
|
||||
- `market-brief-report <keyword>`
|
||||
|
||||
Use command output as primary evidence text. Do not rewrite numeric values.
|
||||
187
remote_sync/websites.remote.yaml
Normal file
187
remote_sync/websites.remote.yaml
Normal file
@ -0,0 +1,187 @@
|
||||
version: 1
|
||||
default_policy:
|
||||
credential_store: env
|
||||
otp_policy: ask_in_chat
|
||||
captcha_policy: pause_and_ask
|
||||
domain_guard: true
|
||||
ask_before_destructive: true
|
||||
routing_rules:
|
||||
prefer_exact_keyword_match: true
|
||||
fallback_site_id: public-search
|
||||
ask_when_ambiguous: true
|
||||
hard_route_rules:
|
||||
- when_keywords:
|
||||
- 小红书
|
||||
- 红书
|
||||
- xhs
|
||||
- 笔记
|
||||
- 种草
|
||||
- 小红书数据
|
||||
force_site_id: xiaohongshu-mcp
|
||||
note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。
|
||||
- when_keywords:
|
||||
- 市场分析
|
||||
- 行业分析
|
||||
- 类目分析
|
||||
- 竞品分析
|
||||
- 趋势分析
|
||||
force_site_id: ecommerce-market-analysis
|
||||
note: 只要提到市场分析相关任务,强制走电商市场分析站点。
|
||||
- when_keywords:
|
||||
- 销售数据
|
||||
- 销售报表
|
||||
- 销售看板
|
||||
- 公司销售
|
||||
- 售后数据
|
||||
- 售后分析
|
||||
- 退款分析
|
||||
- 退货分析
|
||||
- 业绩报表
|
||||
- 费比监控
|
||||
- 业绩数据
|
||||
- 业绩
|
||||
- 上周业绩
|
||||
- 产品信息
|
||||
- 商品ID
|
||||
- SKU编码
|
||||
- 产品材质
|
||||
- 成本价
|
||||
- 预览图
|
||||
- 店铺业绩
|
||||
- 旗舰店业绩
|
||||
- 客服体验
|
||||
- 用户反馈
|
||||
- 话术分析
|
||||
- 上周
|
||||
force_site_id: company-sales-db-analysis
|
||||
note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。
|
||||
sites:
|
||||
- id: xiaohongshu-mcp
|
||||
name: 小红书数据服务(本地MCP)
|
||||
purposes:
|
||||
- 小红书数据查询
|
||||
- 小红书笔记搜索
|
||||
- 小红书互动分析
|
||||
keywords:
|
||||
- 小红书
|
||||
- 红书
|
||||
- xhs
|
||||
- 笔记
|
||||
- 种草
|
||||
login_required: true
|
||||
entry_url: http://127.0.0.1:18060
|
||||
login_url: http://127.0.0.1:18060/api/v1/login/qrcode
|
||||
allowed_domains:
|
||||
- 127.0.0.1
|
||||
- localhost
|
||||
notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。
|
||||
- id: public-search
|
||||
name: 公开搜索(免登录)
|
||||
purposes:
|
||||
- 通用搜索
|
||||
- 公开资料查询
|
||||
- 无需登录任务
|
||||
keywords:
|
||||
- 搜索
|
||||
- 查一下
|
||||
- 公开信息
|
||||
- 网页查询
|
||||
- 新闻
|
||||
- 资料
|
||||
login_required: false
|
||||
entry_url: https://www.bing.com
|
||||
allowed_domains:
|
||||
- bing.com
|
||||
- cn.bing.com
|
||||
- id: ecommerce-market-analysis
|
||||
name: 电商市场分析数据查询
|
||||
purposes:
|
||||
- 电商市场分析
|
||||
- 行业数据查询
|
||||
- 竞品分析
|
||||
- 销量趋势
|
||||
keywords:
|
||||
- 市场分析
|
||||
- 电商分析
|
||||
- 行业数据
|
||||
- 竞品
|
||||
- 趋势
|
||||
- yuce
|
||||
- 预测科技
|
||||
login_required: true
|
||||
entry_url: https://hq.yucekj.com
|
||||
login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F
|
||||
allowed_domains:
|
||||
- hq.yucekj.com
|
||||
- yucekj.com
|
||||
credentials:
|
||||
source: env
|
||||
username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME
|
||||
password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD
|
||||
fallback_source: 1password
|
||||
username_ref: op://Business/ecommerce-market-analysis/username
|
||||
password_ref: op://Business/ecommerce-market-analysis/password
|
||||
notes: 登录后按用户任务提取报表/图表/指标。
|
||||
- id: company-sales-db-analysis
|
||||
name: 公司销售/售后数据库分析
|
||||
purposes:
|
||||
- 公司销售数据查询
|
||||
- 售后与退款分析
|
||||
- 店铺与平台对比
|
||||
- 费用与费比分析
|
||||
- 物流与安装协同分析
|
||||
keywords:
|
||||
- 销售数据
|
||||
- 销售报表
|
||||
- 售后数据
|
||||
- 售后分析
|
||||
- 退款分析
|
||||
- 费比监控
|
||||
- 业绩数据
|
||||
- 业绩
|
||||
- 店铺业绩
|
||||
- 旗舰店业绩
|
||||
- 客服体验
|
||||
- 用户反馈
|
||||
- 话术分析
|
||||
- 产品信息
|
||||
- 商品ID
|
||||
- SKU编码
|
||||
- 产品材质
|
||||
- 成本价
|
||||
- 预览图
|
||||
login_required: false
|
||||
connector: sqlserver
|
||||
data_source:
|
||||
host_env: COMPANY_DB_HOST
|
||||
port_env: COMPANY_DB_PORT
|
||||
database_env: COMPANY_DB_NAME
|
||||
user_env: COMPANY_DB_USER
|
||||
password_env: COMPANY_DB_PASSWORD
|
||||
allowed_tables:
|
||||
- jst_goods_profit_kpi_items_afs
|
||||
- jst_sku
|
||||
- fys_shopInfo
|
||||
- jst_order_express_details
|
||||
- fys_SkuDataLibrary_SpuConfig
|
||||
- op_operManage
|
||||
- op_TargetManagement
|
||||
- fys_Ticketing_install
|
||||
- fys_Ticketing_install_item
|
||||
- fys_jstFreightCost
|
||||
- fys_jstFreightCostDetail
|
||||
- pc_kfChatLog
|
||||
query_principles:
|
||||
- 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。
|
||||
- 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md
|
||||
- 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql
|
||||
- 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`
|
||||
- 商品ID下SKU清单查询优先命令:`product-skus-by-id "<商品id>" --limit 1000`
|
||||
- 客服体验/话术分析优先命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
|
||||
- 客服体验/话术分析输出需包含:问题结构、话术覆盖、风险对话案例、建议回复、按客服名字的能力评分与等级。
|
||||
- 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
- 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。
|
||||
- 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。
|
||||
- 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。
|
||||
- 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。
|
||||
notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。
|
||||
210
remote_sync/websites.yaml
Normal file
210
remote_sync/websites.yaml
Normal file
@ -0,0 +1,210 @@
|
||||
version: 1
|
||||
default_policy:
|
||||
credential_store: env
|
||||
otp_policy: ask_in_chat
|
||||
captcha_policy: pause_and_ask
|
||||
domain_guard: true
|
||||
ask_before_destructive: true
|
||||
routing_rules:
|
||||
prefer_exact_keyword_match: true
|
||||
fallback_site_id: public-search
|
||||
ask_when_ambiguous: true
|
||||
hard_route_rules:
|
||||
- when_keywords:
|
||||
- 小红书
|
||||
- 红书
|
||||
- xhs
|
||||
- 笔记
|
||||
- 种草
|
||||
- 小红书数据
|
||||
force_site_id: xiaohongshu-mcp
|
||||
note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。
|
||||
- when_keywords:
|
||||
- 市场分析
|
||||
- 行业分析
|
||||
- 类目分析
|
||||
- 竞品分析
|
||||
- 趋势分析
|
||||
force_site_id: ecommerce-market-analysis
|
||||
note: 只要提到市场分析相关任务,强制走电商市场分析站点。
|
||||
- when_keywords:
|
||||
- 安装图纸
|
||||
- 安装视频
|
||||
- 安装说明书
|
||||
- 图纸地址
|
||||
- 视频地址
|
||||
- 安装教程
|
||||
- 订单号查图纸
|
||||
- 订单号查视频
|
||||
force_site_id: install-media-db-analysis
|
||||
note: 安装图纸/安装视频相关任务,强制走订单映射+安装资源数据库查询。
|
||||
- when_keywords:
|
||||
- 销售数据
|
||||
- 销售报表
|
||||
- 销售看板
|
||||
- 公司销售
|
||||
- 售后数据
|
||||
- 售后分析
|
||||
- 退款分析
|
||||
- 退货分析
|
||||
- 业绩报表
|
||||
- 费比监控
|
||||
- 业绩数据
|
||||
- 业绩
|
||||
- 上周业绩
|
||||
- 店铺业绩
|
||||
- 旗舰店业绩
|
||||
- 上周
|
||||
force_site_id: company-sales-db-analysis
|
||||
note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。
|
||||
sites:
|
||||
- id: xiaohongshu-mcp
|
||||
name: 小红书数据服务(本地MCP)
|
||||
purposes:
|
||||
- 小红书数据查询
|
||||
- 小红书笔记搜索
|
||||
- 小红书互动分析
|
||||
keywords:
|
||||
- 小红书
|
||||
- 红书
|
||||
- xhs
|
||||
- 笔记
|
||||
- 种草
|
||||
login_required: true
|
||||
entry_url: http://127.0.0.1:18060
|
||||
login_url: http://127.0.0.1:18060/api/v1/login/qrcode
|
||||
allowed_domains:
|
||||
- 127.0.0.1
|
||||
- localhost
|
||||
notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。
|
||||
- id: public-search
|
||||
name: 公开搜索(免登录)
|
||||
purposes:
|
||||
- 通用搜索
|
||||
- 公开资料查询
|
||||
- 无需登录任务
|
||||
keywords:
|
||||
- 搜索
|
||||
- 查一下
|
||||
- 公开信息
|
||||
- 网页查询
|
||||
- 新闻
|
||||
- 资料
|
||||
login_required: false
|
||||
entry_url: https://www.bing.com
|
||||
allowed_domains:
|
||||
- bing.com
|
||||
- cn.bing.com
|
||||
- id: ecommerce-market-analysis
|
||||
name: 电商市场分析数据查询
|
||||
purposes:
|
||||
- 电商市场分析
|
||||
- 行业数据查询
|
||||
- 竞品分析
|
||||
- 销量趋势
|
||||
keywords:
|
||||
- 市场分析
|
||||
- 电商分析
|
||||
- 行业数据
|
||||
- 竞品
|
||||
- 趋势
|
||||
- yuce
|
||||
- 预测科技
|
||||
login_required: true
|
||||
entry_url: https://hq.yucekj.com
|
||||
login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F
|
||||
allowed_domains:
|
||||
- hq.yucekj.com
|
||||
- yucekj.com
|
||||
credentials:
|
||||
source: env
|
||||
username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME
|
||||
password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD
|
||||
fallback_source: 1password
|
||||
username_ref: op://Business/ecommerce-market-analysis/username
|
||||
password_ref: op://Business/ecommerce-market-analysis/password
|
||||
notes: 登录后按用户任务提取报表/图表/指标。
|
||||
- id: install-media-db-analysis
|
||||
name: 安装图纸与安装视频数据库查询
|
||||
purposes:
|
||||
- 订单号查询安装图纸
|
||||
- 订单号查询安装视频
|
||||
- 货号查询安装说明书资源
|
||||
keywords:
|
||||
- 安装图纸
|
||||
- 安装视频
|
||||
- 安装说明书
|
||||
- 图纸地址
|
||||
- 视频地址
|
||||
- 安装教程
|
||||
login_required: false
|
||||
connector: sqlserver+mysql
|
||||
data_source:
|
||||
sqlserver:
|
||||
host_env: COMPANY_DB_HOST
|
||||
port_env: COMPANY_DB_PORT
|
||||
database_env: COMPANY_DB_NAME
|
||||
user_env: COMPANY_DB_USER
|
||||
password_env: COMPANY_DB_PASSWORD
|
||||
mysql:
|
||||
host_env: INSTALL_MEDIA_MYSQL_HOST
|
||||
port_env: INSTALL_MEDIA_MYSQL_PORT
|
||||
user_env: INSTALL_MEDIA_MYSQL_USER
|
||||
password_env: INSTALL_MEDIA_MYSQL_PASSWORD
|
||||
query_principles:
|
||||
- 先按订单号映射产品货号,再按货号查询安装图纸/安装视频。
|
||||
- 优先命令:`install-media-lookup --order-id "<订单号>"`。
|
||||
- 返回时保留 raw_path,同时返回浏览器可访问的 media_url。
|
||||
- 路径转换规则:`D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`。
|
||||
- 若未命中资源,返回空结果与下一步,不编造链接。
|
||||
notes: 专用于安装图纸/视频查询,禁止路由到市场分析或销售报表页面。
|
||||
- id: company-sales-db-analysis
|
||||
name: 公司销售/售后数据库分析
|
||||
purposes:
|
||||
- 公司销售数据查询
|
||||
- 售后与退款分析
|
||||
- 店铺与平台对比
|
||||
- 费用与费比分析
|
||||
- 物流与安装协同分析
|
||||
keywords:
|
||||
- 销售数据
|
||||
- 销售报表
|
||||
- 售后数据
|
||||
- 售后分析
|
||||
- 退款分析
|
||||
- 费比监控
|
||||
- 业绩数据
|
||||
- 业绩
|
||||
- 店铺业绩
|
||||
- 旗舰店业绩
|
||||
login_required: false
|
||||
connector: sqlserver
|
||||
data_source:
|
||||
host_env: COMPANY_DB_HOST
|
||||
port_env: COMPANY_DB_PORT
|
||||
database_env: COMPANY_DB_NAME
|
||||
user_env: COMPANY_DB_USER
|
||||
password_env: COMPANY_DB_PASSWORD
|
||||
allowed_tables:
|
||||
- jst_goods_profit_kpi_items_afs
|
||||
- jst_sku
|
||||
- fys_shopInfo
|
||||
- jst_order_express_details
|
||||
- fys_SkuDataLibrary_SpuConfig
|
||||
- op_operManage
|
||||
- op_TargetManagement
|
||||
- fys_Ticketing_install
|
||||
- fys_Ticketing_install_item
|
||||
- fys_jstFreightCost
|
||||
- fys_jstFreightCostDetail
|
||||
- pc_kfChatLog
|
||||
query_principles:
|
||||
- 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。
|
||||
- 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md
|
||||
- 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql
|
||||
- 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
- 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。
|
||||
- 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。
|
||||
- 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。
|
||||
- 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。
|
||||
notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。
|
||||
53
set_default_feishu_target.py
Normal file
53
set_default_feishu_target.py
Normal file
@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LINK = "https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI"
|
||||
DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g"
|
||||
DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5"
|
||||
|
||||
agents = Path('/root/.openclaw/workspace/AGENTS.md')
|
||||
text = agents.read_text(encoding='utf-8')
|
||||
|
||||
start = text.find('## 5) Feishu Delivery Rules\n')
|
||||
end = text.find('\n## 6) Workspace Paths\n', start)
|
||||
if start == -1 or end == -1:
|
||||
raise SystemExit('Feishu section not found in AGENTS.md')
|
||||
|
||||
new_section = f'''## 5) Feishu Delivery Rules
|
||||
|
||||
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
|
||||
|
||||
Default target (pinned):
|
||||
- default_link: {DEFAULT_LINK}
|
||||
- default_app_token: {DEFAULT_APP_TOKEN}
|
||||
- default_table_id: {DEFAULT_TABLE_ID}
|
||||
|
||||
Required behavior:
|
||||
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
|
||||
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
|
||||
- If user does not provide a link in current turn, ALWAYS use the pinned default target above.
|
||||
- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table.
|
||||
- In final reply, always include:
|
||||
1) push status (success/failed)
|
||||
2) target sheet link (actual link used)
|
||||
3) written record_id (or updated count)
|
||||
- If write partially fails, return succeeded records and failed reason list separately.
|
||||
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
|
||||
|
||||
'''
|
||||
|
||||
text = text[:start] + new_section + text[end+1:]
|
||||
agents.write_text(text, encoding='utf-8')
|
||||
print('updated', agents)
|
||||
|
||||
user = Path('/root/.openclaw/workspace/USER.md')
|
||||
ut = user.read_text(encoding='utf-8')
|
||||
line = f'- 飞书推送默认固定到:{DEFAULT_LINK}'
|
||||
line2 = '- 除非我明确要求切换,不要再向我索要飞书表格链接。'
|
||||
if line not in ut:
|
||||
if not ut.endswith('\n'):
|
||||
ut += '\n'
|
||||
ut += line + '\n'
|
||||
if line2 not in ut:
|
||||
ut += line2 + '\n'
|
||||
user.write_text(ut, encoding='utf-8')
|
||||
print('updated', user)
|
||||
222
sku_stock_risk_report.sql
Normal file
222
sku_stock_risk_report.sql
Normal file
@ -0,0 +1,222 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @sku nvarchar(100) = N'CJ019317N1097V01';
|
||||
DECLARE @lookback_days int = 30;
|
||||
DECLARE @lead_days int = 10;
|
||||
DECLARE @has_exact bit = 0;
|
||||
DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE());
|
||||
DECLARE @end_time datetime2 = GETDATE();
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @sku)
|
||||
SET @has_exact = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL
|
||||
DROP TABLE #matched_sku;
|
||||
|
||||
SELECT TOP (50)
|
||||
s.sku_id,
|
||||
s.name AS sku_name,
|
||||
s.i_id AS style_code,
|
||||
s.other_1 AS spu,
|
||||
s.other_7 AS product_no,
|
||||
s.cost_price,
|
||||
s.other_10 AS board_material,
|
||||
s.pic AS preview_pic_url,
|
||||
s.modified
|
||||
INTO #matched_sku
|
||||
FROM dbo.jst_sku AS s WITH (NOLOCK)
|
||||
WHERE
|
||||
(@has_exact = 1 AND s.sku_id = @sku)
|
||||
OR (
|
||||
@has_exact = 0
|
||||
AND (
|
||||
s.sku_id = @sku
|
||||
OR s.i_id = @sku
|
||||
OR s.other_1 = @sku
|
||||
OR s.other_7 = @sku
|
||||
OR s.sku_id LIKE '%' + @sku + '%'
|
||||
)
|
||||
)
|
||||
ORDER BY CASE WHEN s.sku_id = @sku THEN 0 ELSE 1 END, s.modified DESC;
|
||||
|
||||
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
|
||||
DROP TABLE #inv_latest;
|
||||
|
||||
WITH inv_rank AS (
|
||||
SELECT
|
||||
NULLIF(i.sku_id, '') AS sku_id,
|
||||
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
|
||||
i.wms_co_id,
|
||||
i.name AS inventory_note,
|
||||
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(i.sku_id, '')
|
||||
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
|
||||
) AS rn
|
||||
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
|
||||
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku)
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
stock_qty,
|
||||
order_lock_qty,
|
||||
pick_lock_qty,
|
||||
lock_qty,
|
||||
virtual_qty,
|
||||
defective_qty,
|
||||
purchase_qty,
|
||||
return_qty,
|
||||
in_qty,
|
||||
min_qty,
|
||||
max_qty,
|
||||
wms_co_id,
|
||||
inventory_note,
|
||||
inventory_snapshot_time,
|
||||
CASE
|
||||
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
|
||||
THEN CAST(0 AS decimal(18, 2))
|
||||
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
|
||||
END AS available_qty
|
||||
INTO #inv_latest
|
||||
FROM inv_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
|
||||
DROP TABLE #ship_base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty
|
||||
INTO #ship_base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku)
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL
|
||||
DROP TABLE #ship_daily;
|
||||
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
CAST(sb.ship_dt AS date) AS ship_date,
|
||||
COUNT(DISTINCT sb.order_id) AS order_count,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum
|
||||
INTO #ship_daily
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
GROUP BY sb.sku_id, CAST(sb.ship_dt AS date);
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
|
||||
DROP TABLE #ship_stats;
|
||||
|
||||
SELECT
|
||||
sd.sku_id,
|
||||
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d,
|
||||
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d,
|
||||
MAX(sd.ship_date) AS last_ship_date
|
||||
INTO #ship_stats
|
||||
FROM #ship_daily AS sd
|
||||
GROUP BY sd.sku_id;
|
||||
|
||||
-- 1) SKU基础信息 + 库存快照
|
||||
SELECT
|
||||
ms.sku_id,
|
||||
ms.sku_name,
|
||||
ms.style_code,
|
||||
ms.spu,
|
||||
ms.product_no,
|
||||
ms.cost_price,
|
||||
ms.board_material,
|
||||
ms.preview_pic_url,
|
||||
inv.stock_qty,
|
||||
inv.order_lock_qty,
|
||||
inv.pick_lock_qty,
|
||||
inv.lock_qty,
|
||||
inv.available_qty,
|
||||
inv.inventory_snapshot_time,
|
||||
inv.wms_co_id
|
||||
FROM #matched_sku AS ms
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = ms.sku_id
|
||||
ORDER BY ms.modified DESC;
|
||||
|
||||
-- 2) 近N天每日总发货件数
|
||||
SELECT
|
||||
sd.sku_id,
|
||||
ms.sku_name,
|
||||
sd.ship_date,
|
||||
sd.order_count,
|
||||
sd.shipped_qty_sum
|
||||
FROM #ship_daily AS sd
|
||||
LEFT JOIN #matched_sku AS ms
|
||||
ON ms.sku_id = sd.sku_id
|
||||
ORDER BY sd.ship_date DESC;
|
||||
|
||||
-- 3) 缺货预估与补货建议(补货安全线=10天)
|
||||
SELECT
|
||||
ms.sku_id,
|
||||
ms.sku_name,
|
||||
inv.available_qty,
|
||||
ss.last_ship_date,
|
||||
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
|
||||
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
|
||||
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
|
||||
END AS decimal(18, 2)
|
||||
) AS estimated_stockout_days,
|
||||
@lead_days AS replenishment_lead_days,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
|
||||
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
|
||||
END AS decimal(18, 2)
|
||||
) AS suggest_replenish_qty_to_10d,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN '无库存快照'
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
|
||||
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN '补货预警(10-15天)'
|
||||
ELSE '库存安全'
|
||||
END AS stock_risk_level
|
||||
FROM #matched_sku AS ms
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = ms.sku_id
|
||||
LEFT JOIN #ship_stats AS ss
|
||||
ON ss.sku_id = ms.sku_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN 9
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 8
|
||||
WHEN inv.available_qty <= 0 THEN 1
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN 2
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
estimated_stockout_days ASC;
|
||||
71
sqlserver/README_SQL_TEMPLATES.md
Normal file
71
sqlserver/README_SQL_TEMPLATES.md
Normal file
@ -0,0 +1,71 @@
|
||||
# 销售分析常用 SQL 模板包
|
||||
|
||||
数据源:`JSDataMiddlePlatform`(SQLServer)
|
||||
执行方式:`company-db run-file <sql文件路径>`
|
||||
|
||||
默认分析口径(已按最新要求):
|
||||
- 主口径优先:`发货件数 / 售后件数 / 订单数`
|
||||
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
|
||||
|
||||
## 模板列表
|
||||
|
||||
1. `shop_weekly_report.sql`
|
||||
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
2. `sku_structure_report.sql`
|
||||
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
|
||||
- 默认时间:最近30天
|
||||
|
||||
3. `refund_reason_report.sql`
|
||||
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
|
||||
- 默认时间:最近30天
|
||||
|
||||
4. `fee_ratio_alert.sql`
|
||||
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
|
||||
- 默认时间:最近14天
|
||||
|
||||
5. `logistics_sla_report.sql`
|
||||
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
|
||||
- 默认时间:最近30天
|
||||
|
||||
6. `freight_efficiency_report.sql`
|
||||
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
|
||||
- 默认时间:最近30天
|
||||
|
||||
7. `install_service_quality_report.sql`
|
||||
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
|
||||
- 默认时间:最近90天
|
||||
|
||||
8. `customer_service_hotspot_report.sql`
|
||||
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
|
||||
- 默认时间:最近30天
|
||||
|
||||
9. `link_target_coverage_report.sql`
|
||||
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
|
||||
- 默认时间:在线链接全量
|
||||
|
||||
10. `after_sale_360_report.sql`
|
||||
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
|
||||
- 默认时间:上一个自然周
|
||||
|
||||
## 运行示例
|
||||
|
||||
```bash
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql
|
||||
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。
|
||||
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
|
||||
- 如需自定义时间,修改每个模板顶部变量即可。
|
||||
193
sqlserver/after_sale_360_report.sql
Normal file
193
sqlserver/after_sale_360_report.sql
Normal file
@ -0,0 +1,193 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 售后360分析(退款 + 原因 + 工单 + 客诉)
|
||||
Window: 上一个自然周 [week_start, week_end)
|
||||
*/
|
||||
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
|
||||
DROP TABLE #chat_latest;
|
||||
|
||||
WITH chat_rank AS (
|
||||
SELECT
|
||||
NULLIF(c.orderNo, '') AS order_id,
|
||||
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
|
||||
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
|
||||
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
|
||||
c.createdAt,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(c.orderNo, '')
|
||||
ORDER BY c.createdAt DESC, c.id DESC
|
||||
) AS rn
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
WHERE c.createdAt >= @week_start
|
||||
AND c.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
|
||||
)
|
||||
SELECT
|
||||
order_id,
|
||||
main_reason,
|
||||
second_reason,
|
||||
third_reason
|
||||
INTO #chat_latest
|
||||
FROM chat_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
|
||||
DROP TABLE #afs;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
|
||||
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
|
||||
ISNULL(cl.main_reason, '') AS chat_main_reason,
|
||||
ISNULL(cl.second_reason, '') AS chat_second_reason,
|
||||
ISNULL(cl.third_reason, '') AS chat_third_reason,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l1,
|
||||
CASE
|
||||
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l2,
|
||||
CASE
|
||||
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l3,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
|
||||
ELSE 'unknown'
|
||||
END AS reason_source,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
|
||||
INTO #afs
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
LEFT JOIN #chat_latest AS cl
|
||||
ON cl.order_id = NULLIF(a.undefined2, '')
|
||||
WHERE a.createdAt >= @week_start
|
||||
AND a.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#install') IS NOT NULL
|
||||
DROP TABLE #install;
|
||||
|
||||
SELECT
|
||||
NULLIF(i.OrderNum, '') AS order_id,
|
||||
i.SubmitTime,
|
||||
TRY_CONVERT(int, i.StatusId) AS status_id,
|
||||
TRY_CONVERT(int, i.installComplete) AS install_complete,
|
||||
TRY_CONVERT(decimal(18, 2), i.Num) AS install_num,
|
||||
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
|
||||
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
|
||||
TRY_CONVERT(int, i.kfsh) AS kf_audit,
|
||||
TRY_CONVERT(int, i.cwsh) AS cw_audit,
|
||||
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
|
||||
ISNULL(NULLIF(i.City, ''), 'unknown') AS city
|
||||
INTO #install
|
||||
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
|
||||
WHERE i.SubmitTime >= @week_start
|
||||
AND i.SubmitTime < @week_end
|
||||
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> ''
|
||||
AND (i.[Delete] = 0 OR i.[Delete] IS NULL);
|
||||
|
||||
-- 1) 售后总览(退款口径)
|
||||
SELECT
|
||||
@week_start AS week_start,
|
||||
@week_end AS week_end,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(return_amount, 0)) AS return_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate
|
||||
FROM #afs;
|
||||
|
||||
-- 2) 售后原因分析(一级原因 + 来源)
|
||||
SELECT TOP (30)
|
||||
afs_reason_l1 AS afs_reason,
|
||||
reason_source,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS reason_refund_rate
|
||||
FROM #afs
|
||||
GROUP BY afs_reason_l1, reason_source
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 3) 售后二级原因 TOP20
|
||||
SELECT TOP (20)
|
||||
afs_reason_l1,
|
||||
afs_reason_l2,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #afs
|
||||
GROUP BY afs_reason_l1, afs_reason_l2
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 4) 售后类型/结果矩阵
|
||||
SELECT
|
||||
afs_type,
|
||||
afs_result,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #afs
|
||||
GROUP BY afs_type, afs_result
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 5) 安装工单概览(同周期)
|
||||
SELECT
|
||||
COUNT(*) AS install_ticket_count,
|
||||
COUNT(DISTINCT order_id) AS install_order_count,
|
||||
SUM(ISNULL(install_num, 0)) AS install_piece_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS install_payment_amount,
|
||||
SUM(ISNULL(install_cost, 0)) AS install_cost,
|
||||
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
|
||||
SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count,
|
||||
SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count
|
||||
FROM #install;
|
||||
|
||||
-- 6) 售后退款与安装工单联动(按订单)
|
||||
SELECT
|
||||
COUNT(*) AS overlap_order_count,
|
||||
SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount,
|
||||
SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost,
|
||||
SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count
|
||||
FROM (
|
||||
SELECT DISTINCT order_id, refund_amount
|
||||
FROM #afs
|
||||
WHERE ISNULL(order_id, '') <> ''
|
||||
) a
|
||||
INNER JOIN #install i
|
||||
ON i.order_id = a.order_id;
|
||||
79
sqlserver/customer_service_hotspot_report.sql
Normal file
79
sqlserver/customer_service_hotspot_report.sql
Normal file
@ -0,0 +1,79 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 客服问题热点分析
|
||||
Window: 最近30天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
c.id,
|
||||
c.orderNo AS order_id,
|
||||
ISNULL(NULLIF(c.shopName, ''), 'unknown') AS shop_name,
|
||||
ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name,
|
||||
ISNULL(NULLIF(c.mainReason, ''), '未标注') AS main_reason,
|
||||
ISNULL(NULLIF(c.secondReason, ''), '未标注') AS second_reason,
|
||||
ISNULL(NULLIF(c.thirdReason, ''), '未标注') AS third_reason,
|
||||
c.createdAt
|
||||
INTO #base
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
|
||||
ON e.so_id = c.orderNo
|
||||
WHERE c.createdAt >= @date_from
|
||||
AND c.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(c.orderNo, ''), '') <> '';
|
||||
|
||||
-- 1) 总览
|
||||
SELECT
|
||||
@date_from AS date_from,
|
||||
@date_to AS date_to,
|
||||
COUNT(*) AS chat_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
COUNT(DISTINCT shop_name) AS shop_count
|
||||
FROM #base;
|
||||
|
||||
-- 2) 一级问题 TOP30
|
||||
SELECT TOP (30)
|
||||
platform_name,
|
||||
main_reason,
|
||||
COUNT(*) AS chat_count,
|
||||
COUNT(DISTINCT order_id) AS order_count
|
||||
FROM #base
|
||||
GROUP BY platform_name, main_reason
|
||||
ORDER BY chat_count DESC;
|
||||
|
||||
-- 3) 一级+二级问题组合 TOP30
|
||||
SELECT TOP (30)
|
||||
main_reason,
|
||||
second_reason,
|
||||
COUNT(*) AS chat_count,
|
||||
COUNT(DISTINCT order_id) AS order_count
|
||||
FROM #base
|
||||
GROUP BY main_reason, second_reason
|
||||
ORDER BY chat_count DESC;
|
||||
|
||||
-- 4) 店铺热点问题
|
||||
SELECT TOP (50)
|
||||
shop_name,
|
||||
platform_name,
|
||||
main_reason,
|
||||
COUNT(*) AS chat_count,
|
||||
COUNT(DISTINCT order_id) AS order_count
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name, main_reason
|
||||
ORDER BY chat_count DESC;
|
||||
|
||||
-- 5) 每日趋势
|
||||
SELECT
|
||||
CAST(createdAt AS date) AS biz_date,
|
||||
COUNT(*) AS chat_count,
|
||||
COUNT(DISTINCT order_id) AS order_count
|
||||
FROM #base
|
||||
GROUP BY CAST(createdAt AS date)
|
||||
ORDER BY biz_date DESC;
|
||||
179
sqlserver/fee_ratio_alert.sql
Normal file
179
sqlserver/fee_ratio_alert.sql
Normal file
@ -0,0 +1,179 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 费比异常预警
|
||||
Window: 最近14天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -14, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
CAST(a.createdAt AS date) AS biz_date,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined82, '')) AS fee_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined85, '')) AS fee_taoke,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined86, '')) AS fee_freight_insurance,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS fee_promotion,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined102, '')) AS fee_software_service,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')) AS fee_taobao_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')) AS fee_jd_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')) AS fee_pdd_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')) AS fee_taogongchang_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')) AS fee_alibaba_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')) AS fee_douyin_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')) AS fee_maoning_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')) AS fee_vip_mp_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')) AS fee_vip_commission,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')) AS fee_dewu_commission,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
|
||||
INTO #base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @date_from
|
||||
AND a.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#agg') IS NOT NULL
|
||||
DROP TABLE #agg;
|
||||
|
||||
SELECT
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(
|
||||
ABS(ISNULL(fee_commission, 0))
|
||||
+ ABS(ISNULL(fee_taoke, 0))
|
||||
+ ABS(ISNULL(fee_freight_insurance, 0))
|
||||
+ ABS(ISNULL(fee_promotion, 0))
|
||||
+ ABS(ISNULL(fee_software_service, 0))
|
||||
+ ABS(ISNULL(fee_taobao_commission, 0))
|
||||
+ ABS(ISNULL(fee_jd_commission, 0))
|
||||
+ ABS(ISNULL(fee_pdd_commission, 0))
|
||||
+ ABS(ISNULL(fee_taogongchang_commission, 0))
|
||||
+ ABS(ISNULL(fee_alibaba_commission, 0))
|
||||
+ ABS(ISNULL(fee_douyin_commission, 0))
|
||||
+ ABS(ISNULL(fee_maoning_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_mp_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_commission, 0))
|
||||
+ ABS(ISNULL(fee_dewu_commission, 0))
|
||||
) AS fee_amount_abs
|
||||
INTO #agg
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name;
|
||||
|
||||
-- 1) 全局预警总览
|
||||
SELECT
|
||||
@date_from AS date_from,
|
||||
@date_to AS date_to,
|
||||
COUNT(*) AS shop_count,
|
||||
SUM(order_count) AS order_count,
|
||||
SUM(payment_amount) AS payment_amount,
|
||||
SUM(refund_amount) AS refund_amount,
|
||||
SUM(fee_amount_abs) AS fee_amount_abs,
|
||||
CASE
|
||||
WHEN SUM(payment_amount) = 0 THEN NULL
|
||||
ELSE SUM(refund_amount) / SUM(payment_amount)
|
||||
END AS refund_rate,
|
||||
CASE
|
||||
WHEN SUM(payment_amount) = 0 THEN NULL
|
||||
ELSE SUM(fee_amount_abs) / SUM(payment_amount)
|
||||
END AS fee_abs_rate
|
||||
FROM #agg;
|
||||
|
||||
-- 2) 店铺级异常清单
|
||||
SELECT
|
||||
shop_name,
|
||||
platform_name,
|
||||
order_count,
|
||||
payment_amount,
|
||||
refund_amount,
|
||||
fee_amount_abs,
|
||||
CASE
|
||||
WHEN payment_amount = 0 THEN NULL
|
||||
ELSE refund_amount / payment_amount
|
||||
END AS refund_rate,
|
||||
CASE
|
||||
WHEN payment_amount = 0 THEN NULL
|
||||
ELSE fee_amount_abs / payment_amount
|
||||
END AS fee_abs_rate,
|
||||
CASE
|
||||
WHEN payment_amount = 0 THEN 'P3'
|
||||
WHEN (fee_amount_abs / payment_amount) >= 0.35 OR (refund_amount / payment_amount) >= 0.25 THEN 'P1'
|
||||
WHEN (fee_amount_abs / payment_amount) >= 0.25 OR (refund_amount / payment_amount) >= 0.18 THEN 'P2'
|
||||
ELSE 'P3'
|
||||
END AS alert_level
|
||||
FROM #agg
|
||||
WHERE order_count >= 30
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN payment_amount = 0 THEN 0
|
||||
ELSE fee_amount_abs / payment_amount
|
||||
END DESC,
|
||||
refund_amount DESC;
|
||||
|
||||
-- 3) 日趋势(全局)
|
||||
SELECT
|
||||
biz_date,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(
|
||||
ABS(ISNULL(fee_commission, 0))
|
||||
+ ABS(ISNULL(fee_taoke, 0))
|
||||
+ ABS(ISNULL(fee_freight_insurance, 0))
|
||||
+ ABS(ISNULL(fee_promotion, 0))
|
||||
+ ABS(ISNULL(fee_software_service, 0))
|
||||
+ ABS(ISNULL(fee_taobao_commission, 0))
|
||||
+ ABS(ISNULL(fee_jd_commission, 0))
|
||||
+ ABS(ISNULL(fee_pdd_commission, 0))
|
||||
+ ABS(ISNULL(fee_taogongchang_commission, 0))
|
||||
+ ABS(ISNULL(fee_alibaba_commission, 0))
|
||||
+ ABS(ISNULL(fee_douyin_commission, 0))
|
||||
+ ABS(ISNULL(fee_maoning_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_mp_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_commission, 0))
|
||||
+ ABS(ISNULL(fee_dewu_commission, 0))
|
||||
) AS fee_amount_abs,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(
|
||||
ABS(ISNULL(fee_commission, 0))
|
||||
+ ABS(ISNULL(fee_taoke, 0))
|
||||
+ ABS(ISNULL(fee_freight_insurance, 0))
|
||||
+ ABS(ISNULL(fee_promotion, 0))
|
||||
+ ABS(ISNULL(fee_software_service, 0))
|
||||
+ ABS(ISNULL(fee_taobao_commission, 0))
|
||||
+ ABS(ISNULL(fee_jd_commission, 0))
|
||||
+ ABS(ISNULL(fee_pdd_commission, 0))
|
||||
+ ABS(ISNULL(fee_taogongchang_commission, 0))
|
||||
+ ABS(ISNULL(fee_alibaba_commission, 0))
|
||||
+ ABS(ISNULL(fee_douyin_commission, 0))
|
||||
+ ABS(ISNULL(fee_maoning_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_mp_commission, 0))
|
||||
+ ABS(ISNULL(fee_vip_commission, 0))
|
||||
+ ABS(ISNULL(fee_dewu_commission, 0))
|
||||
) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS fee_abs_rate
|
||||
FROM #base
|
||||
GROUP BY biz_date
|
||||
ORDER BY biz_date DESC;
|
||||
103
sqlserver/freight_efficiency_report.sql
Normal file
103
sqlserver/freight_efficiency_report.sql
Normal file
@ -0,0 +1,103 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 运费效率分析
|
||||
Window: 最近30天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
fc.so_id AS order_id,
|
||||
ISNULL(NULLIF(fc.shop_name, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(fc.shop_name, '')) > 1
|
||||
THEN LEFT(fc.shop_name, CHARINDEX('-', fc.shop_name) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
ISNULL(NULLIF(fc.receiver_state, ''), 'unknown') AS receiver_state,
|
||||
ISNULL(NULLIF(fc.expressName, ''), 'unknown') AS express_name,
|
||||
TRY_CONVERT(decimal(18, 2), fc.weight) AS weight,
|
||||
TRY_CONVERT(decimal(18, 2), fc.freightCost) AS freight_cost,
|
||||
fc.send_date,
|
||||
fc.type AS freight_type
|
||||
INTO #base
|
||||
FROM dbo.fys_jstFreightCost AS fc WITH (NOLOCK)
|
||||
WHERE fc.send_date >= @date_from
|
||||
AND fc.send_date < @date_to
|
||||
AND ISNULL(NULLIF(fc.so_id, ''), '') <> '';
|
||||
|
||||
-- 1) 总览
|
||||
SELECT
|
||||
@date_from AS date_from,
|
||||
@date_to AS date_to,
|
||||
COUNT(*) AS shipment_count,
|
||||
SUM(ISNULL(weight, 0)) AS total_weight,
|
||||
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
|
||||
END AS avg_cost_per_order,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
|
||||
END AS avg_cost_per_kg
|
||||
FROM #base;
|
||||
|
||||
-- 2) 平台运费效率
|
||||
SELECT
|
||||
platform_name,
|
||||
COUNT(*) AS shipment_count,
|
||||
SUM(ISNULL(weight, 0)) AS total_weight,
|
||||
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
|
||||
END AS avg_cost_per_order,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
|
||||
END AS avg_cost_per_kg
|
||||
FROM #base
|
||||
GROUP BY platform_name
|
||||
ORDER BY shipment_count DESC;
|
||||
|
||||
-- 3) 店铺运费异常(发货单>=100)
|
||||
SELECT TOP (50)
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(*) AS shipment_count,
|
||||
SUM(ISNULL(weight, 0)) AS total_weight,
|
||||
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
|
||||
END AS avg_cost_per_order,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
|
||||
END AS avg_cost_per_kg
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name
|
||||
HAVING COUNT(*) >= 100
|
||||
ORDER BY avg_cost_per_kg DESC, shipment_count DESC;
|
||||
|
||||
-- 4) 省份与快递公司组合成本
|
||||
SELECT TOP (50)
|
||||
receiver_state,
|
||||
express_name,
|
||||
COUNT(*) AS shipment_count,
|
||||
SUM(ISNULL(weight, 0)) AS total_weight,
|
||||
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
|
||||
END AS avg_cost_per_kg
|
||||
FROM #base
|
||||
GROUP BY receiver_state, express_name
|
||||
ORDER BY total_freight_cost DESC;
|
||||
106
sqlserver/install_service_quality_report.sql
Normal file
106
sqlserver/install_service_quality_report.sql
Normal file
@ -0,0 +1,106 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 安装服务质量分析
|
||||
Window: 最近90天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -90, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#ticket') IS NOT NULL
|
||||
DROP TABLE #ticket;
|
||||
|
||||
SELECT
|
||||
i.Id,
|
||||
i.SubmitTime,
|
||||
i.OrderNum,
|
||||
ISNULL(NULLIF(i.ShopCode, ''), 'unknown') AS shop_code,
|
||||
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
|
||||
ISNULL(NULLIF(i.City, ''), 'unknown') AS city,
|
||||
ISNULL(NULLIF(i.Operation, ''), 'unknown') AS operation_user,
|
||||
ISNULL(NULLIF(i.sourceFrom, ''), 'unknown') AS source_from,
|
||||
TRY_CONVERT(int, i.StatusId) AS status_id,
|
||||
TRY_CONVERT(int, i.installComplete) AS install_complete,
|
||||
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), i.FinalPayMent) AS final_payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
|
||||
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
|
||||
TRY_CONVERT(decimal(18, 2), i.acceptAmount) AS accept_amount,
|
||||
TRY_CONVERT(int, i.kfsh) AS kf_audit,
|
||||
TRY_CONVERT(int, i.cwsh) AS cw_audit,
|
||||
i.SourceGuid
|
||||
INTO #ticket
|
||||
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
|
||||
WHERE i.SubmitTime >= @date_from
|
||||
AND i.SubmitTime < @date_to
|
||||
AND (i.[Delete] = 0 OR i.[Delete] IS NULL)
|
||||
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> '';
|
||||
|
||||
IF OBJECT_ID('tempdb..#item_agg') IS NOT NULL
|
||||
DROP TABLE #item_agg;
|
||||
|
||||
SELECT
|
||||
ii.guid AS source_guid,
|
||||
COUNT(*) AS item_count,
|
||||
SUM(ISNULL(ii.installcount, 0)) AS install_count,
|
||||
SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum,
|
||||
SUM(ISNULL(ii.applyItemAmount, 0)) AS apply_item_amount_sum,
|
||||
SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum,
|
||||
SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum,
|
||||
MAX(ISNULL(NULLIF(ii.scmname, ''), 'unknown')) AS sample_scm_name
|
||||
INTO #item_agg
|
||||
FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK)
|
||||
GROUP BY ii.guid;
|
||||
|
||||
-- 1) 总览
|
||||
SELECT
|
||||
@date_from AS date_from,
|
||||
@date_to AS date_to,
|
||||
COUNT(*) AS ticket_count,
|
||||
COUNT(DISTINCT ordernum) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(final_payment_amount, 0)) AS final_payment_amount,
|
||||
SUM(ISNULL(install_cost, 0)) AS install_cost,
|
||||
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
|
||||
SUM(ISNULL(accept_amount, 0)) AS accept_amount
|
||||
FROM #ticket;
|
||||
|
||||
-- 2) 工单状态分布
|
||||
SELECT
|
||||
status_id,
|
||||
install_complete,
|
||||
COUNT(*) AS ticket_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(install_cost, 0)) AS install_cost
|
||||
FROM #ticket
|
||||
GROUP BY status_id, install_complete
|
||||
ORDER BY ticket_count DESC;
|
||||
|
||||
-- 3) 服务商表现(取明细表scm)
|
||||
SELECT TOP (30)
|
||||
ISNULL(NULLIF(ii.scmname, ''), 'unknown') AS scm_name,
|
||||
COUNT(*) AS item_count,
|
||||
SUM(ISNULL(ii.installcount, 0)) AS install_count,
|
||||
SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum,
|
||||
SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum,
|
||||
SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum
|
||||
FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK)
|
||||
WHERE ii.createTime >= @date_from
|
||||
AND ii.createTime < @date_to
|
||||
GROUP BY ISNULL(NULLIF(ii.scmname, ''), 'unknown')
|
||||
ORDER BY item_count DESC;
|
||||
|
||||
-- 4) 区域问题清单(客服/财务未审核)
|
||||
SELECT TOP (50)
|
||||
t.province,
|
||||
t.city,
|
||||
COUNT(*) AS pending_ticket_count,
|
||||
SUM(ISNULL(t.payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(t.install_cost, 0)) AS install_cost
|
||||
FROM #ticket AS t
|
||||
WHERE ISNULL(t.kf_audit, 0) <> 1
|
||||
OR ISNULL(t.cw_audit, 0) <> 1
|
||||
GROUP BY t.province, t.city
|
||||
ORDER BY pending_ticket_count DESC, payment_amount DESC;
|
||||
103
sqlserver/last_week_afs_report.sql
Normal file
103
sqlserver/last_week_afs_report.sql
Normal file
@ -0,0 +1,103 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Unified logical layer (temp-table based, no CREATE VIEW permission required).
|
||||
Week window = previous natural week:
|
||||
[start_of_last_week, start_of_this_week)
|
||||
*/
|
||||
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#filtered') IS NOT NULL
|
||||
DROP TABLE #filtered;
|
||||
|
||||
SELECT
|
||||
a.id,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined1, '') AS inner_order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
NULLIF(a.undefined9, '') AS shop_code,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined56, '') AS afs_type,
|
||||
NULLIF(a.undefined57, '') AS afs_reason,
|
||||
NULLIF(a.undefined58, '') AS afs_result,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount,
|
||||
a.createdAt AS created_at,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name
|
||||
INTO #filtered
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @week_start
|
||||
AND a.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
-- 1) weekly overview
|
||||
SELECT
|
||||
MIN(created_at) AS data_min_time,
|
||||
MAX(created_at) AS data_max_time,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount,
|
||||
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered;
|
||||
|
||||
-- 2) platform distribution
|
||||
SELECT
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered
|
||||
GROUP BY platform_name
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 3) shop top 20
|
||||
SELECT TOP (20)
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
|
||||
FROM #filtered
|
||||
GROUP BY shop_name, platform_name
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 4) reason top 20
|
||||
SELECT TOP (20)
|
||||
ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount
|
||||
FROM #filtered
|
||||
GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注')
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 5) type/result matrix
|
||||
SELECT
|
||||
ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum
|
||||
FROM #filtered
|
||||
GROUP BY
|
||||
ISNULL(NULLIF(afs_type, ''), '未标注'),
|
||||
ISNULL(NULLIF(afs_result, ''), '未标注')
|
||||
ORDER BY afs_record_count DESC;
|
||||
193
sqlserver/last_week_after_sale_analysis.sql
Normal file
193
sqlserver/last_week_after_sale_analysis.sql
Normal file
@ -0,0 +1,193 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 售后360分析(退款 + 原因 + 工单 + 客诉)
|
||||
Window: 上一个自然周 [week_start, week_end)
|
||||
*/
|
||||
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
|
||||
DROP TABLE #chat_latest;
|
||||
|
||||
WITH chat_rank AS (
|
||||
SELECT
|
||||
NULLIF(c.orderNo, '') AS order_id,
|
||||
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
|
||||
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
|
||||
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
|
||||
c.createdAt,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(c.orderNo, '')
|
||||
ORDER BY c.createdAt DESC, c.id DESC
|
||||
) AS rn
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
WHERE c.createdAt >= @week_start
|
||||
AND c.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
|
||||
)
|
||||
SELECT
|
||||
order_id,
|
||||
main_reason,
|
||||
second_reason,
|
||||
third_reason
|
||||
INTO #chat_latest
|
||||
FROM chat_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
|
||||
DROP TABLE #afs;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
|
||||
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
|
||||
ISNULL(cl.main_reason, '') AS chat_main_reason,
|
||||
ISNULL(cl.second_reason, '') AS chat_second_reason,
|
||||
ISNULL(cl.third_reason, '') AS chat_third_reason,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l1,
|
||||
CASE
|
||||
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l2,
|
||||
CASE
|
||||
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l3,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
|
||||
ELSE 'unknown'
|
||||
END AS reason_source,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
|
||||
INTO #afs
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
LEFT JOIN #chat_latest AS cl
|
||||
ON cl.order_id = NULLIF(a.undefined2, '')
|
||||
WHERE a.createdAt >= @week_start
|
||||
AND a.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#install') IS NOT NULL
|
||||
DROP TABLE #install;
|
||||
|
||||
SELECT
|
||||
NULLIF(i.OrderNum, '') AS order_id,
|
||||
i.SubmitTime,
|
||||
TRY_CONVERT(int, i.StatusId) AS status_id,
|
||||
TRY_CONVERT(int, i.installComplete) AS install_complete,
|
||||
TRY_CONVERT(decimal(18, 2), i.Num) AS install_num,
|
||||
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
|
||||
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
|
||||
TRY_CONVERT(int, i.kfsh) AS kf_audit,
|
||||
TRY_CONVERT(int, i.cwsh) AS cw_audit,
|
||||
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
|
||||
ISNULL(NULLIF(i.City, ''), 'unknown') AS city
|
||||
INTO #install
|
||||
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
|
||||
WHERE i.SubmitTime >= @week_start
|
||||
AND i.SubmitTime < @week_end
|
||||
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> ''
|
||||
AND (i.[Delete] = 0 OR i.[Delete] IS NULL);
|
||||
|
||||
-- 1) 售后总览(退款口径)
|
||||
SELECT
|
||||
@week_start AS week_start,
|
||||
@week_end AS week_end,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS afs_order_count,
|
||||
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(return_amount, 0)) AS return_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate
|
||||
FROM #afs;
|
||||
|
||||
-- 2) 售后原因分析(一级原因 + 来源)
|
||||
SELECT TOP (30)
|
||||
afs_reason_l1 AS afs_reason,
|
||||
reason_source,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS reason_refund_rate
|
||||
FROM #afs
|
||||
GROUP BY afs_reason_l1, reason_source
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 3) 售后二级原因 TOP20
|
||||
SELECT TOP (20)
|
||||
afs_reason_l1,
|
||||
afs_reason_l2,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #afs
|
||||
GROUP BY afs_reason_l1, afs_reason_l2
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 4) 售后类型/结果矩阵
|
||||
SELECT
|
||||
afs_type,
|
||||
afs_result,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #afs
|
||||
GROUP BY afs_type, afs_result
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 5) 安装工单概览(同周期)
|
||||
SELECT
|
||||
COUNT(*) AS install_ticket_count,
|
||||
COUNT(DISTINCT order_id) AS install_order_count,
|
||||
SUM(ISNULL(install_num, 0)) AS install_piece_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS install_payment_amount,
|
||||
SUM(ISNULL(install_cost, 0)) AS install_cost,
|
||||
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
|
||||
SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count,
|
||||
SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count
|
||||
FROM #install;
|
||||
|
||||
-- 6) 售后退款与安装工单联动(按订单)
|
||||
SELECT
|
||||
COUNT(*) AS overlap_order_count,
|
||||
SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount,
|
||||
SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost,
|
||||
SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count
|
||||
FROM (
|
||||
SELECT DISTINCT order_id, refund_amount
|
||||
FROM #afs
|
||||
WHERE ISNULL(order_id, '') <> ''
|
||||
) a
|
||||
INNER JOIN #install i
|
||||
ON i.order_id = a.order_id;
|
||||
77
sqlserver/link_target_coverage_report.sql
Normal file
77
sqlserver/link_target_coverage_report.sql
Normal file
@ -0,0 +1,77 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 链接目标覆盖分析(运营视角)
|
||||
Window: 在售链接(linkStatus = 当前在线)
|
||||
*/
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
t.Id,
|
||||
ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name,
|
||||
ISNULL(NULLIF(t.category, ''), 'unknown') AS category,
|
||||
ISNULL(NULLIF(t.shopGoodsCategory, ''), 'unknown') AS shop_goods_category,
|
||||
ISNULL(NULLIF(t.spu, ''), 'unknown') AS spu,
|
||||
ISNULL(NULLIF(t.linkType, ''), 'unknown') AS link_type,
|
||||
ISNULL(NULLIF(t.linkStatus, ''), 'unknown') AS link_status,
|
||||
TRY_CONVERT(int, t.onSaleDays) AS on_sale_days,
|
||||
TRY_CONVERT(decimal(18, 2), t.salesMoneyTarget) AS sales_money_target,
|
||||
TRY_CONVERT(int, t.salesAmountTarget) AS sales_amount_target,
|
||||
TRY_CONVERT(decimal(18, 2), t.salesMoneyTargetAvg) AS sales_money_target_avg,
|
||||
TRY_CONVERT(int, t.salesAmountTargetAvg) AS sales_amount_target_avg,
|
||||
ISNULL(NULLIF(t.oper, ''), 'unknown') AS oper,
|
||||
ISNULL(NULLIF(t.cater, ''), 'unknown') AS cater,
|
||||
ISNULL(NULLIF(t.ider, ''), 'unknown') AS designer,
|
||||
ISNULL(NULLIF(t.dept, ''), 'unknown') AS dept,
|
||||
ISNULL(NULLIF(t.topsgn, ''), 'unknown') AS topsgn,
|
||||
ISNULL(NULLIF(t.bind, ''), 'unknown') AS price_band_label,
|
||||
TRY_CONVERT(decimal(18, 2), t.priceBand) AS sale_floor_price
|
||||
INTO #base
|
||||
FROM dbo.op_TargetManagement AS t WITH (NOLOCK)
|
||||
WHERE ISNULL(NULLIF(t.linkStatus, ''), 'unknown') = '当前在线';
|
||||
|
||||
-- 1) 在线链接总览
|
||||
SELECT
|
||||
COUNT(*) AS online_link_count,
|
||||
COUNT(DISTINCT shop_name) AS shop_count,
|
||||
COUNT(DISTINCT spu) AS spu_count,
|
||||
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target,
|
||||
SUM(ISNULL(sales_amount_target, 0)) AS sales_amount_target
|
||||
FROM #base;
|
||||
|
||||
-- 2) 店铺覆盖度
|
||||
SELECT TOP (50)
|
||||
shop_name,
|
||||
COUNT(*) AS online_link_count,
|
||||
COUNT(DISTINCT spu) AS spu_count,
|
||||
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target,
|
||||
AVG(ISNULL(on_sale_days, 0)) AS avg_on_sale_days
|
||||
FROM #base
|
||||
GROUP BY shop_name
|
||||
ORDER BY online_link_count DESC;
|
||||
|
||||
-- 3) 运营人负载
|
||||
SELECT TOP (50)
|
||||
dept,
|
||||
oper,
|
||||
COUNT(*) AS online_link_count,
|
||||
COUNT(DISTINCT shop_name) AS shop_count,
|
||||
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target
|
||||
FROM #base
|
||||
GROUP BY dept, oper
|
||||
ORDER BY online_link_count DESC;
|
||||
|
||||
-- 4) 类目与价格带分布
|
||||
SELECT
|
||||
category,
|
||||
shop_goods_category,
|
||||
price_band_label,
|
||||
COUNT(*) AS online_link_count,
|
||||
AVG(ISNULL(sale_floor_price, 0)) AS avg_sale_floor_price,
|
||||
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target
|
||||
FROM #base
|
||||
GROUP BY category, shop_goods_category, price_band_label
|
||||
ORDER BY online_link_count DESC;
|
||||
95
sqlserver/logistics_sla_report.sql
Normal file
95
sqlserver/logistics_sla_report.sql
Normal file
@ -0,0 +1,95 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 物流时效/SLA分析
|
||||
Window: 最近30天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
e.so_id AS order_id,
|
||||
ISNULL(NULLIF(e.shop_name, ''), 'unknown') AS shop_name,
|
||||
ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name,
|
||||
ISNULL(NULLIF(e.logistic_company_name, ''), 'unknown') AS logistic_company_name,
|
||||
ISNULL(NULLIF(e.logistic_status_name, ''), 'unknown') AS logistic_status_name,
|
||||
e.pay_time,
|
||||
e.send_time,
|
||||
e.fetch_time,
|
||||
e.sign_time,
|
||||
TRY_CONVERT(decimal(18, 4), e.weight) AS weight,
|
||||
TRY_CONVERT(decimal(18, 4), e.f_weight) AS f_weight,
|
||||
CASE
|
||||
WHEN e.pay_time IS NOT NULL AND e.send_time IS NOT NULL
|
||||
THEN DATEDIFF(HOUR, e.pay_time, e.send_time)
|
||||
ELSE NULL
|
||||
END AS pay_to_send_h,
|
||||
CASE
|
||||
WHEN e.send_time IS NOT NULL AND e.sign_time IS NOT NULL
|
||||
THEN DATEDIFF(HOUR, e.send_time, e.sign_time)
|
||||
ELSE NULL
|
||||
END AS send_to_sign_h,
|
||||
CASE
|
||||
WHEN e.pay_time IS NOT NULL AND e.sign_time IS NOT NULL
|
||||
THEN DATEDIFF(HOUR, e.pay_time, e.sign_time)
|
||||
ELSE NULL
|
||||
END AS pay_to_sign_h
|
||||
INTO #base
|
||||
FROM dbo.jst_order_express_details AS e WITH (NOLOCK)
|
||||
WHERE e.createdAt >= @date_from
|
||||
AND e.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(e.so_id, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
-- 1) 全局时效总览
|
||||
SELECT
|
||||
@date_from AS date_from,
|
||||
@date_to AS date_to,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN sign_time IS NOT NULL THEN 1 ELSE 0 END) AS signed_order_count,
|
||||
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
|
||||
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
|
||||
AVG(CASE WHEN pay_to_sign_h >= 0 THEN CAST(pay_to_sign_h AS decimal(18, 2)) END) AS avg_pay_to_sign_h
|
||||
FROM #base;
|
||||
|
||||
-- 2) 平台时效
|
||||
SELECT
|
||||
platform_name,
|
||||
COUNT(*) AS order_count,
|
||||
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
|
||||
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
|
||||
SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h,
|
||||
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
|
||||
FROM #base
|
||||
GROUP BY platform_name
|
||||
ORDER BY order_count DESC;
|
||||
|
||||
-- 3) 店铺时效预警(订单数>=100)
|
||||
SELECT TOP (50)
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(*) AS order_count,
|
||||
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
|
||||
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
|
||||
SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h,
|
||||
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name
|
||||
HAVING COUNT(*) >= 100
|
||||
ORDER BY delayed_send_over_48h DESC, avg_send_to_sign_h DESC;
|
||||
|
||||
-- 4) 快递公司表现
|
||||
SELECT TOP (30)
|
||||
logistic_company_name,
|
||||
COUNT(*) AS order_count,
|
||||
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
|
||||
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
|
||||
FROM #base
|
||||
GROUP BY logistic_company_name
|
||||
ORDER BY order_count DESC;
|
||||
161
sqlserver/refund_reason_report.sql
Normal file
161
sqlserver/refund_reason_report.sql
Normal file
@ -0,0 +1,161 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 退款原因分析
|
||||
Window: 最近30天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
|
||||
DROP TABLE #chat_latest;
|
||||
|
||||
WITH chat_rank AS (
|
||||
SELECT
|
||||
NULLIF(c.orderNo, '') AS order_id,
|
||||
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
|
||||
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
|
||||
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(c.orderNo, '')
|
||||
ORDER BY c.createdAt DESC, c.id DESC
|
||||
) AS rn
|
||||
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
|
||||
WHERE c.createdAt >= @date_from
|
||||
AND c.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
|
||||
)
|
||||
SELECT
|
||||
order_id,
|
||||
main_reason,
|
||||
second_reason,
|
||||
third_reason
|
||||
INTO #chat_latest
|
||||
FROM chat_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
|
||||
ISNULL(NULLIF(a.undefined57, ''), '') AS native_afs_reason,
|
||||
ISNULL(cl.main_reason, '') AS chat_main_reason,
|
||||
ISNULL(cl.second_reason, '') AS chat_second_reason,
|
||||
ISNULL(cl.third_reason, '') AS chat_third_reason,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l1,
|
||||
CASE
|
||||
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l2,
|
||||
CASE
|
||||
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
|
||||
ELSE '未标注'
|
||||
END AS afs_reason_l3,
|
||||
CASE
|
||||
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
|
||||
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
|
||||
ELSE 'unknown'
|
||||
END AS reason_source,
|
||||
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
|
||||
INTO #base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
LEFT JOIN #chat_latest AS cl
|
||||
ON cl.order_id = NULLIF(a.undefined2, '')
|
||||
WHERE a.createdAt >= @date_from
|
||||
AND a.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
-- 1) 全局退款原因 TOP30
|
||||
SELECT TOP (30)
|
||||
afs_reason_l1 AS afs_reason,
|
||||
reason_source,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(return_amount, 0)) AS return_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS reason_refund_rate
|
||||
FROM #base
|
||||
GROUP BY afs_reason_l1, reason_source
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 2) 平台内原因 TOP10
|
||||
WITH reason_rank AS (
|
||||
SELECT
|
||||
platform_name,
|
||||
afs_reason_l1 AS afs_reason,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY platform_name
|
||||
ORDER BY COUNT(*) DESC
|
||||
) AS rn
|
||||
FROM #base
|
||||
GROUP BY platform_name, afs_reason_l1
|
||||
)
|
||||
SELECT
|
||||
platform_name,
|
||||
afs_reason,
|
||||
afs_record_count,
|
||||
refund_amount
|
||||
FROM reason_rank
|
||||
WHERE rn <= 10
|
||||
ORDER BY platform_name, afs_record_count DESC;
|
||||
|
||||
-- 3) 二级原因 TOP20(可用于更细分原因)
|
||||
SELECT TOP (20)
|
||||
afs_reason_l1,
|
||||
afs_reason_l2,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #base
|
||||
GROUP BY afs_reason_l1, afs_reason_l2
|
||||
ORDER BY afs_record_count DESC;
|
||||
|
||||
-- 4) 店铺退款风险清单(订单数>=50)
|
||||
SELECT
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name
|
||||
HAVING COUNT(DISTINCT order_id) >= 50
|
||||
ORDER BY refund_rate DESC, payment_amount DESC;
|
||||
|
||||
-- 5) 售后类型/结果矩阵
|
||||
SELECT
|
||||
afs_type,
|
||||
afs_result,
|
||||
COUNT(*) AS afs_record_count,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount
|
||||
FROM #base
|
||||
GROUP BY afs_type, afs_result
|
||||
ORDER BY afs_record_count DESC;
|
||||
111
sqlserver/shop_weekly_report.sql
Normal file
111
sqlserver/shop_weekly_report.sql
Normal file
@ -0,0 +1,111 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: 店铺周报
|
||||
Window: 上一个自然周 [week_start, week_end)
|
||||
*/
|
||||
|
||||
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
|
||||
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
|
||||
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
|
||||
ELSE 'unknown'
|
||||
END AS platform_name,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')) AS goods_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')) AS goods_cost,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
|
||||
a.createdAt AS created_at
|
||||
INTO #base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @week_start
|
||||
AND a.createdAt < @week_end
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
-- 1) 周总览
|
||||
SELECT
|
||||
@week_start AS week_start,
|
||||
@week_end AS week_end,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(qty, 0)) AS qty_sum,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(actual_amount, 0)) AS actual_amount,
|
||||
SUM(ISNULL(actual_cost, 0)) AS actual_cost,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
SUM(ISNULL(return_amount, 0)) AS return_amount
|
||||
FROM #base;
|
||||
|
||||
-- 2) 平台周报
|
||||
SELECT
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate,
|
||||
SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit
|
||||
FROM #base
|
||||
GROUP BY platform_name
|
||||
ORDER BY payment_amount DESC;
|
||||
|
||||
-- 3) 店铺 TOP30
|
||||
SELECT TOP (30)
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate,
|
||||
SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(actual_amount, 0)) = 0 THEN NULL
|
||||
ELSE (SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0))) / SUM(ISNULL(actual_amount, 0))
|
||||
END AS gross_margin_rate
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name
|
||||
ORDER BY payment_amount DESC;
|
||||
|
||||
-- 4) 高退款风险店铺(门槛:订单数>=50)
|
||||
SELECT
|
||||
shop_name,
|
||||
platform_name,
|
||||
COUNT(DISTINCT order_id) AS order_count,
|
||||
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END AS refund_rate
|
||||
FROM #base
|
||||
GROUP BY shop_name, platform_name
|
||||
HAVING COUNT(DISTINCT order_id) >= 50
|
||||
AND CASE
|
||||
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN 0
|
||||
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
|
||||
END >= 0.15
|
||||
ORDER BY refund_rate DESC, payment_amount DESC;
|
||||
124
sqlserver/sku_structure_report.sql
Normal file
124
sqlserver/sku_structure_report.sql
Normal file
@ -0,0 +1,124 @@
|
||||
USE [JSDataMiddlePlatform];
|
||||
GO
|
||||
|
||||
/*
|
||||
Template: SKU结构分析
|
||||
Window: 最近30天 [date_from, date_to)
|
||||
*/
|
||||
|
||||
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
|
||||
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
|
||||
|
||||
IF OBJECT_ID('tempdb..#base') IS NOT NULL
|
||||
DROP TABLE #base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost,
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
|
||||
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
|
||||
INTO #base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE a.createdAt >= @date_from
|
||||
AND a.createdAt < @date_to
|
||||
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined10, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_agg') IS NOT NULL
|
||||
DROP TABLE #sku_agg;
|
||||
|
||||
SELECT
|
||||
b.sku_id,
|
||||
ISNULL(NULLIF(s.name, ''), 'unknown') AS sku_name,
|
||||
ISNULL(NULLIF(s.other_1, ''), 'unknown') AS spu,
|
||||
ISNULL(NULLIF(s.category, ''), 'unknown') AS category,
|
||||
s.sale_price,
|
||||
COUNT(*) AS afs_record_count,
|
||||
COUNT(DISTINCT b.order_id) AS order_count,
|
||||
SUM(ISNULL(b.qty, 0)) AS qty_sum,
|
||||
SUM(ISNULL(b.payment_amount, 0)) AS payment_amount,
|
||||
SUM(ISNULL(b.actual_amount, 0)) AS actual_amount,
|
||||
SUM(ISNULL(b.actual_cost, 0)) AS actual_cost,
|
||||
SUM(ISNULL(b.refund_amount, 0)) AS refund_amount
|
||||
INTO #sku_agg
|
||||
FROM #base AS b
|
||||
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
|
||||
ON s.sku_id = b.sku_id
|
||||
GROUP BY
|
||||
b.sku_id,
|
||||
s.name,
|
||||
s.other_1,
|
||||
s.category,
|
||||
s.sale_price;
|
||||
|
||||
-- 1) 类目结构(按销售额)
|
||||
SELECT TOP (20)
|
||||
category,
|
||||
COUNT(*) AS sku_count,
|
||||
SUM(payment_amount) AS payment_amount,
|
||||
CASE
|
||||
WHEN SUM(SUM(payment_amount)) OVER () = 0 THEN NULL
|
||||
ELSE SUM(payment_amount) / SUM(SUM(payment_amount)) OVER ()
|
||||
END AS payment_share
|
||||
FROM #sku_agg
|
||||
GROUP BY category
|
||||
ORDER BY payment_amount DESC;
|
||||
|
||||
-- 2) SKU TOP50(按销售额)
|
||||
SELECT TOP (50)
|
||||
sku_id,
|
||||
sku_name,
|
||||
spu,
|
||||
category,
|
||||
sale_price,
|
||||
order_count,
|
||||
qty_sum,
|
||||
payment_amount,
|
||||
refund_amount,
|
||||
CASE
|
||||
WHEN payment_amount = 0 THEN NULL
|
||||
ELSE refund_amount / payment_amount
|
||||
END AS refund_rate,
|
||||
actual_amount - actual_cost AS gross_profit,
|
||||
CASE
|
||||
WHEN actual_amount = 0 THEN NULL
|
||||
ELSE (actual_amount - actual_cost) / actual_amount
|
||||
END AS gross_margin_rate
|
||||
FROM #sku_agg
|
||||
ORDER BY payment_amount DESC;
|
||||
|
||||
-- 3) 价格带结构
|
||||
WITH price_band AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN sale_price IS NULL THEN '未知价格'
|
||||
WHEN sale_price < 100 THEN '0-99'
|
||||
WHEN sale_price < 200 THEN '100-199'
|
||||
WHEN sale_price < 300 THEN '200-299'
|
||||
WHEN sale_price < 500 THEN '300-499'
|
||||
ELSE '500+'
|
||||
END AS price_band,
|
||||
payment_amount,
|
||||
order_count,
|
||||
refund_amount
|
||||
FROM #sku_agg
|
||||
)
|
||||
SELECT
|
||||
price_band,
|
||||
SUM(order_count) AS order_count,
|
||||
SUM(payment_amount) AS payment_amount,
|
||||
SUM(refund_amount) AS refund_amount,
|
||||
CASE
|
||||
WHEN SUM(payment_amount) = 0 THEN NULL
|
||||
ELSE SUM(refund_amount) / SUM(payment_amount)
|
||||
END AS refund_rate
|
||||
FROM price_band
|
||||
GROUP BY price_band
|
||||
ORDER BY payment_amount DESC;
|
||||
488
stock-risk-alerts
Executable file
488
stock-risk-alerts
Executable file
@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def build_sql(days: int, lead_days: int, warn_days: int, limit: int, include_safe: bool) -> str:
|
||||
include_safe_bit = 1 if include_safe else 0
|
||||
return f"""
|
||||
USE [JSDataMiddlePlatform];
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @lookback_days int = {days};
|
||||
DECLARE @lead_days int = {lead_days};
|
||||
DECLARE @warn_days int = {warn_days};
|
||||
DECLARE @limit int = {limit};
|
||||
DECLARE @include_safe bit = {include_safe_bit};
|
||||
DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE());
|
||||
DECLARE @end_time datetime2 = GETDATE();
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
|
||||
DROP TABLE #ship_base;
|
||||
|
||||
SELECT
|
||||
NULLIF(a.undefined10, '') AS sku_id,
|
||||
NULLIF(a.undefined2, '') AS order_id,
|
||||
NULLIF(a.undefined12, '') AS product_id,
|
||||
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
|
||||
CASE
|
||||
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
|
||||
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
|
||||
ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道')
|
||||
END AS shop_channel,
|
||||
ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name,
|
||||
COALESCE(
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
|
||||
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
|
||||
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
|
||||
TRY_CONVERT(datetime2, a.createdAt)
|
||||
) AS ship_dt,
|
||||
COALESCE(
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
|
||||
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
|
||||
CAST(0 AS decimal(18, 2))
|
||||
) AS shipped_qty
|
||||
INTO #ship_base
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
|
||||
WHERE ISNULL(NULLIF(a.undefined10, ''), '') <> ''
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
|
||||
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
|
||||
|
||||
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
|
||||
DROP TABLE #ship_stats;
|
||||
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
COUNT(DISTINCT sb.order_id) AS order_count_30d,
|
||||
COUNT(DISTINCT CASE WHEN ISNULL(sb.product_id, '') <> '' THEN sb.product_id END) AS product_id_count_30d,
|
||||
COUNT(DISTINCT sb.shop_name) AS shop_count_30d,
|
||||
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -7, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_7d,
|
||||
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -30, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_30d,
|
||||
MAX(sb.ship_dt) AS last_ship_time
|
||||
INTO #ship_stats
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
GROUP BY sb.sku_id;
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_ref') IS NOT NULL
|
||||
DROP TABLE #sku_ref;
|
||||
|
||||
SELECT
|
||||
s.sku_id,
|
||||
s.name AS sku_name,
|
||||
s.other_1 AS spu,
|
||||
s.i_id AS style_code,
|
||||
s.category,
|
||||
s.item_type,
|
||||
s.brand,
|
||||
s.supplier_name,
|
||||
s.other_3 AS default_warehouse,
|
||||
s.other_5 AS sku_status,
|
||||
s.cost_price,
|
||||
s.other_10 AS board_material,
|
||||
s.modified
|
||||
INTO #sku_ref
|
||||
FROM dbo.jst_sku AS s WITH (NOLOCK)
|
||||
WHERE NULLIF(s.sku_id, '') IN (SELECT sku_id FROM #ship_stats);
|
||||
|
||||
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
|
||||
DROP TABLE #inv_latest;
|
||||
|
||||
WITH inv_rank AS (
|
||||
SELECT
|
||||
NULLIF(i.sku_id, '') AS sku_id,
|
||||
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
|
||||
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
|
||||
i.wms_co_id,
|
||||
i.name AS inventory_note,
|
||||
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NULLIF(i.sku_id, '')
|
||||
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
|
||||
) AS rn
|
||||
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
|
||||
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #ship_stats)
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
stock_qty,
|
||||
order_lock_qty,
|
||||
pick_lock_qty,
|
||||
lock_qty,
|
||||
virtual_qty,
|
||||
defective_qty,
|
||||
purchase_qty,
|
||||
return_qty,
|
||||
in_qty,
|
||||
min_qty,
|
||||
max_qty,
|
||||
wms_co_id,
|
||||
inventory_note,
|
||||
inventory_snapshot_time,
|
||||
CASE
|
||||
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
|
||||
THEN CAST(0 AS decimal(18, 2))
|
||||
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
|
||||
END AS available_qty
|
||||
INTO #inv_latest
|
||||
FROM inv_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL
|
||||
DROP TABLE #sku_owner;
|
||||
|
||||
SELECT
|
||||
sr.sku_id,
|
||||
COALESCE(
|
||||
NULLIF(pc_exact.purchaser, ''),
|
||||
NULLIF(pc_style.purchaser, ''),
|
||||
NULLIF(pc_base.purchaser, ''),
|
||||
'未分配'
|
||||
) AS purchaser_name,
|
||||
COALESCE(
|
||||
NULLIF(po_exact.oper, ''),
|
||||
NULLIF(po_style.oper, ''),
|
||||
NULLIF(po_base.oper, ''),
|
||||
NULLIF(pc_exact.oper, ''),
|
||||
NULLIF(pc_style.oper, ''),
|
||||
NULLIF(pc_base.oper, ''),
|
||||
'未分配'
|
||||
) AS spu_oper_name
|
||||
INTO #sku_owner
|
||||
FROM #sku_ref AS sr
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = NULLIF(sr.spu, '')
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_exact
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = NULLIF(sr.style_code, '')
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_style
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 c.purchaser, c.oper
|
||||
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
|
||||
WHERE NULLIF(c.spu, '') = CASE
|
||||
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
|
||||
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY c.Id DESC
|
||||
) AS pc_base
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = NULLIF(sr.spu, '')
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_exact
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = NULLIF(sr.style_code, '')
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_style
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 o.oper
|
||||
FROM dbo.op_operManage AS o WITH (NOLOCK)
|
||||
WHERE NULLIF(o.spu, '') = CASE
|
||||
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
|
||||
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY o.Id DESC
|
||||
) AS po_base;
|
||||
|
||||
IF OBJECT_ID('tempdb..#operator_top') IS NOT NULL
|
||||
DROP TABLE #operator_top;
|
||||
|
||||
WITH oper_rank AS (
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
NULLIF(t.oper, '') AS oper_name,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY sb.sku_id
|
||||
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, NULLIF(t.oper, '') ASC
|
||||
) AS rn
|
||||
FROM #ship_base AS sb
|
||||
INNER JOIN dbo.op_TargetManagement AS t WITH (NOLOCK)
|
||||
ON NULLIF(t.shopproid, '') = NULLIF(sb.product_id, '')
|
||||
AND ISNULL(NULLIF(t.shopname, ''), '') = ISNULL(NULLIF(sb.shop_name, ''), '')
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
AND ISNULL(NULLIF(t.oper, ''), '') <> ''
|
||||
GROUP BY sb.sku_id, NULLIF(t.oper, '')
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
oper_name AS operator_name
|
||||
INTO #operator_top
|
||||
FROM oper_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#channel_top') IS NOT NULL
|
||||
DROP TABLE #channel_top;
|
||||
|
||||
WITH channel_rank AS (
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
sb.shop_channel,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY sb.sku_id
|
||||
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_channel ASC
|
||||
) AS rn
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
GROUP BY sb.sku_id, sb.shop_channel
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
shop_channel AS top_shop_channel,
|
||||
shipped_qty_sum_30d AS top_channel_shipped_qty_30d
|
||||
INTO #channel_top
|
||||
FROM channel_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#shop_top') IS NOT NULL
|
||||
DROP TABLE #shop_top;
|
||||
|
||||
WITH shop_rank AS (
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
sb.shop_name,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY sb.sku_id
|
||||
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_name ASC
|
||||
) AS rn
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
GROUP BY sb.sku_id, sb.shop_name
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
shop_name AS top_shop_name,
|
||||
shipped_qty_sum_30d AS top_shop_shipped_qty_30d
|
||||
INTO #shop_top
|
||||
FROM shop_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#warehouse_top') IS NOT NULL
|
||||
DROP TABLE #warehouse_top;
|
||||
|
||||
WITH wh_rank AS (
|
||||
SELECT
|
||||
sb.sku_id,
|
||||
sb.ship_warehouse_name,
|
||||
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY sb.sku_id
|
||||
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.ship_warehouse_name ASC
|
||||
) AS rn
|
||||
FROM #ship_base AS sb
|
||||
WHERE sb.ship_dt >= @start_time
|
||||
AND sb.ship_dt < @end_time
|
||||
AND ISNULL(NULLIF(sb.ship_warehouse_name, ''), '') <> ''
|
||||
GROUP BY sb.sku_id, sb.ship_warehouse_name
|
||||
)
|
||||
SELECT
|
||||
sku_id,
|
||||
ship_warehouse_name AS top_warehouse_name
|
||||
INTO #warehouse_top
|
||||
FROM wh_rank
|
||||
WHERE rn = 1;
|
||||
|
||||
IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL
|
||||
DROP TABLE #risk_eval;
|
||||
|
||||
SELECT
|
||||
sr.sku_id,
|
||||
sr.sku_name,
|
||||
sr.spu,
|
||||
sr.category,
|
||||
sr.item_type,
|
||||
sr.brand,
|
||||
sr.cost_price,
|
||||
sr.board_material,
|
||||
sr.sku_status,
|
||||
COALESCE(NULLIF(sr.supplier_name, ''), '未标注') AS factory_name,
|
||||
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name,
|
||||
COALESCE(NULLIF(ot.operator_name, ''), NULLIF(ow.spu_oper_name, ''), '未分配') AS operator_name,
|
||||
COALESCE(NULLIF(wt.top_warehouse_name, ''), NULLIF(sr.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name,
|
||||
ct.top_shop_channel,
|
||||
stp.top_shop_name,
|
||||
ISNULL(ss.product_id_count_30d, 0) AS product_id_count_30d,
|
||||
ISNULL(ss.shop_count_30d, 0) AS shop_count_30d,
|
||||
ISNULL(ss.order_count_30d, 0) AS order_count_30d,
|
||||
ISNULL(ss.shipped_qty_7d, 0) AS shipped_qty_7d,
|
||||
ISNULL(ss.shipped_qty_30d, 0) AS shipped_qty_30d,
|
||||
ss.last_ship_time,
|
||||
inv.stock_qty,
|
||||
inv.order_lock_qty,
|
||||
inv.pick_lock_qty,
|
||||
inv.lock_qty,
|
||||
inv.available_qty,
|
||||
inv.virtual_qty,
|
||||
inv.defective_qty,
|
||||
inv.purchase_qty,
|
||||
inv.return_qty,
|
||||
inv.in_qty,
|
||||
inv.min_qty,
|
||||
inv.max_qty,
|
||||
inv.inventory_snapshot_time,
|
||||
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
|
||||
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
|
||||
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
|
||||
END AS decimal(18, 2)
|
||||
) AS estimated_stockout_days,
|
||||
@lead_days AS replenishment_lead_days,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
|
||||
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
|
||||
END AS decimal(18, 2)
|
||||
) AS suggest_replenish_qty_to_lead_days,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN '无库存快照'
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
|
||||
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)'
|
||||
ELSE '库存安全'
|
||||
END AS stock_risk_level,
|
||||
CASE
|
||||
WHEN inv.sku_id IS NULL THEN 1
|
||||
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
|
||||
WHEN inv.available_qty <= 0 THEN 1
|
||||
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN 1
|
||||
ELSE 0
|
||||
END AS has_stock_risk
|
||||
INTO #risk_eval
|
||||
FROM #ship_stats AS ss
|
||||
LEFT JOIN #sku_ref AS sr
|
||||
ON sr.sku_id = ss.sku_id
|
||||
LEFT JOIN #inv_latest AS inv
|
||||
ON inv.sku_id = ss.sku_id
|
||||
LEFT JOIN #sku_owner AS ow
|
||||
ON ow.sku_id = ss.sku_id
|
||||
LEFT JOIN #operator_top AS ot
|
||||
ON ot.sku_id = ss.sku_id
|
||||
LEFT JOIN #channel_top AS ct
|
||||
ON ct.sku_id = ss.sku_id
|
||||
LEFT JOIN #shop_top AS stp
|
||||
ON stp.sku_id = ss.sku_id
|
||||
LEFT JOIN #warehouse_top AS wt
|
||||
ON wt.sku_id = ss.sku_id;
|
||||
|
||||
-- 1) 汇总:近30天在售SKU + 缺货风险SKU数量
|
||||
SELECT
|
||||
COUNT(*) AS sold_sku_count_30d,
|
||||
SUM(CASE WHEN has_stock_risk = 1 THEN 1 ELSE 0 END) AS stock_risk_sku_count,
|
||||
SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN 1 ELSE 0 END) AS urgent_sku_count,
|
||||
SUM(CASE WHEN stock_risk_level LIKE '补货预警%' THEN 1 ELSE 0 END) AS warning_sku_count,
|
||||
@lookback_days AS lookback_days,
|
||||
@lead_days AS replenishment_lead_days,
|
||||
@warn_days AS warning_days
|
||||
FROM #risk_eval;
|
||||
|
||||
-- 2) 明细:全量SKU风险清单(默认只显示风险项)
|
||||
SELECT TOP (@limit)
|
||||
sku_id,
|
||||
sku_name,
|
||||
spu,
|
||||
category,
|
||||
item_type,
|
||||
brand,
|
||||
sku_status,
|
||||
cost_price,
|
||||
board_material,
|
||||
factory_name,
|
||||
purchaser_name,
|
||||
operator_name,
|
||||
warehouse_name,
|
||||
top_shop_channel,
|
||||
top_shop_name,
|
||||
product_id_count_30d,
|
||||
shop_count_30d,
|
||||
order_count_30d,
|
||||
shipped_qty_7d,
|
||||
shipped_qty_30d,
|
||||
avg_daily_shipped_7d,
|
||||
avg_daily_shipped_30d,
|
||||
stock_qty,
|
||||
order_lock_qty,
|
||||
pick_lock_qty,
|
||||
lock_qty,
|
||||
available_qty,
|
||||
estimated_stockout_days,
|
||||
replenishment_lead_days,
|
||||
suggest_replenish_qty_to_lead_days,
|
||||
stock_risk_level,
|
||||
inventory_snapshot_time,
|
||||
last_ship_time
|
||||
FROM #risk_eval
|
||||
WHERE @include_safe = 1
|
||||
OR has_stock_risk = 1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN stock_risk_level LIKE '缺货%' THEN 1
|
||||
WHEN stock_risk_level LIKE '补货紧急%' THEN 2
|
||||
WHEN stock_risk_level LIKE '补货预警%' THEN 3
|
||||
WHEN stock_risk_level = '无库存快照' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
estimated_stockout_days ASC,
|
||||
shipped_qty_30d DESC,
|
||||
sku_id ASC;
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="All-SKU stock risk alert report")
|
||||
parser.add_argument("--days", type=int, default=30, help="lookback window days, default 30")
|
||||
parser.add_argument("--lead-days", type=int, default=10, help="replenishment lead time days")
|
||||
parser.add_argument("--warn-days", type=int, default=15, help="warning line days (must >= lead-days)")
|
||||
parser.add_argument("--limit", type=int, default=500, help="max rows for detail result set")
|
||||
parser.add_argument("--include-safe", action="store_true", help="include safe SKUs in detail output")
|
||||
args = parser.parse_args()
|
||||
|
||||
days = max(7, min(args.days, 180))
|
||||
lead_days = max(1, min(args.lead_days, 60))
|
||||
warn_days = max(lead_days, min(args.warn_days, 90))
|
||||
limit = max(1, min(args.limit, 5000))
|
||||
|
||||
sql = build_sql(days, lead_days, warn_days, limit, args.include_safe)
|
||||
cmd = ["company-db", "query", "--sql", sql]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "company-db failed").strip()
|
||||
print(err, file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
print(proc.stdout.rstrip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
306
stock-risk-to-feishu
Executable file
306
stock-risk-to-feishu
Executable file
@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g"
|
||||
DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5"
|
||||
DEFAULT_TABLE_LINK = (
|
||||
"https://acnuoqgha8fk.feishu.cn/wiki/"
|
||||
"RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI"
|
||||
)
|
||||
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
|
||||
|
||||
|
||||
REQUIRED_FIELDS = [
|
||||
"记录类型",
|
||||
"批次ID",
|
||||
"报表时间",
|
||||
"统计周期天数",
|
||||
"补货提前期天数",
|
||||
"预警阈值天数",
|
||||
"在售SKU数",
|
||||
"风险SKU数",
|
||||
"紧急SKU数",
|
||||
"预警SKU数",
|
||||
"SKU编码",
|
||||
"SKU名称",
|
||||
"SPU",
|
||||
"风险等级",
|
||||
"预计缺货天数",
|
||||
"建议补货件数",
|
||||
"可用库存",
|
||||
"近7天发货件数",
|
||||
"近30天发货件数",
|
||||
"近30天日均发货",
|
||||
"渠道",
|
||||
"店铺",
|
||||
"采购",
|
||||
"运营",
|
||||
"工厂",
|
||||
"仓库",
|
||||
]
|
||||
|
||||
|
||||
def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict:
|
||||
data = None
|
||||
req_headers = dict(headers)
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"request failed {method} {url}: {e}") from e
|
||||
|
||||
|
||||
def _load_feishu_app() -> Tuple[str, str]:
|
||||
app_id = os.getenv("FEISHU_APP_ID", "").strip()
|
||||
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
|
||||
if app_id and app_secret:
|
||||
return app_id, app_secret
|
||||
|
||||
cfg_path = Path(OPENCLAW_CONFIG)
|
||||
if not cfg_path.exists():
|
||||
raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)")
|
||||
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
ch = (cfg.get("channels") or {}).get("feishu") or {}
|
||||
app_id = (ch.get("appId") or "").strip()
|
||||
app_secret = (ch.get("appSecret") or "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise RuntimeError("feishu appId/appSecret is empty in openclaw.json")
|
||||
return app_id, app_secret
|
||||
|
||||
|
||||
def _tenant_token(app_id: str, app_secret: str) -> str:
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
res = _http_json(
|
||||
"POST",
|
||||
url,
|
||||
headers={},
|
||||
payload={"app_id": app_id, "app_secret": app_secret},
|
||||
)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"feishu auth failed: {res}")
|
||||
token = res.get("tenant_access_token")
|
||||
if not token:
|
||||
raise RuntimeError("feishu auth succeeded but tenant_access_token missing")
|
||||
return token
|
||||
|
||||
|
||||
def _parse_company_rows(output: str) -> Dict[int, Dict]:
|
||||
result_sets: Dict[int, Dict] = {}
|
||||
current_idx = None
|
||||
current_cols: List[str] = []
|
||||
current_rows: List[Dict] = []
|
||||
|
||||
def flush():
|
||||
nonlocal current_idx, current_cols, current_rows
|
||||
if current_idx is not None:
|
||||
result_sets[current_idx] = {"columns": current_cols, "rows": current_rows}
|
||||
current_idx = None
|
||||
current_cols = []
|
||||
current_rows = []
|
||||
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("RESULT_SET "):
|
||||
flush()
|
||||
current_idx = int(line.split()[1])
|
||||
continue
|
||||
if line.startswith("COLUMNS:"):
|
||||
cols = line.split(":", 1)[1].strip()
|
||||
current_cols = [c.strip() for c in cols.split(",")]
|
||||
continue
|
||||
if line.startswith("{") and line.endswith("}"):
|
||||
try:
|
||||
current_rows.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
flush()
|
||||
return result_sets
|
||||
|
||||
|
||||
def _run_stock_risk(days: int, lead_days: int, warn_days: int, limit: int) -> Tuple[Dict, List[Dict]]:
|
||||
cmd = [
|
||||
"stock-risk-alerts",
|
||||
"--days",
|
||||
str(days),
|
||||
"--lead-days",
|
||||
str(lead_days),
|
||||
"--warn-days",
|
||||
str(warn_days),
|
||||
"--limit",
|
||||
str(limit),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "stock-risk-alerts failed").strip()
|
||||
raise RuntimeError(err)
|
||||
parsed = _parse_company_rows(proc.stdout or "")
|
||||
summary_rows = parsed.get(1, {}).get("rows", [])
|
||||
detail_rows = parsed.get(2, {}).get("rows", [])
|
||||
if not summary_rows:
|
||||
raise RuntimeError("stock-risk-alerts returned no summary rows")
|
||||
return summary_rows[0], detail_rows
|
||||
|
||||
|
||||
def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]:
|
||||
url = (
|
||||
f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
"?page_size=500"
|
||||
)
|
||||
res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"})
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"list fields failed: {res}")
|
||||
items = (((res.get("data") or {}).get("items")) or [])
|
||||
out: Dict[str, str] = {}
|
||||
for it in items:
|
||||
name = it.get("field_name")
|
||||
fid = it.get("field_id")
|
||||
if name and fid:
|
||||
out[name] = fid
|
||||
return out
|
||||
|
||||
|
||||
def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
payload = {"field_name": field_name, "type": 1}
|
||||
res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"create field failed ({field_name}): {res}")
|
||||
|
||||
|
||||
def _ensure_fields(token: str, app_token: str, table_id: str) -> None:
|
||||
fields = _list_fields(token, app_token, table_id)
|
||||
for name in REQUIRED_FIELDS:
|
||||
if name not in fields:
|
||||
_create_field(token, app_token, table_id, name)
|
||||
# wait a bit for schema consistency
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> str:
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
payload = {"fields": fields}
|
||||
res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload)
|
||||
if res.get("code") != 0:
|
||||
raise RuntimeError(f"create record failed: {res}")
|
||||
rec = (res.get("data") or {}).get("record") or {}
|
||||
return rec.get("record_id") or rec.get("id") or ""
|
||||
|
||||
|
||||
def _fmt(v) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, float):
|
||||
return f"{v:.2f}".rstrip("0").rstrip(".")
|
||||
return str(v)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Push stock risk alerts to Feishu as structured rows")
|
||||
parser.add_argument("--days", type=int, default=30)
|
||||
parser.add_argument("--lead-days", type=int, default=10)
|
||||
parser.add_argument("--warn-days", type=int, default=15)
|
||||
parser.add_argument("--top", type=int, default=50, help="top N detail rows to push")
|
||||
parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN)
|
||||
parser.add_argument("--table-id", default=DEFAULT_TABLE_ID)
|
||||
parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK)
|
||||
args = parser.parse_args()
|
||||
|
||||
days = max(7, min(args.days, 180))
|
||||
lead_days = max(1, min(args.lead_days, 60))
|
||||
warn_days = max(lead_days, min(args.warn_days, 90))
|
||||
top = max(1, min(args.top, 500))
|
||||
|
||||
summary, details = _run_stock_risk(days, lead_days, warn_days, limit=max(top, 50))
|
||||
|
||||
app_id, app_secret = _load_feishu_app()
|
||||
token = _tenant_token(app_id, app_secret)
|
||||
_ensure_fields(token, args.app_token, args.table_id)
|
||||
|
||||
batch_id = time.strftime("%Y%m%d%H%M%S")
|
||||
now_text = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
summary_record = {
|
||||
"记录类型": "汇总",
|
||||
"批次ID": batch_id,
|
||||
"报表时间": now_text,
|
||||
"统计周期天数": _fmt(summary.get("lookback_days", days)),
|
||||
"补货提前期天数": _fmt(summary.get("replenishment_lead_days", lead_days)),
|
||||
"预警阈值天数": _fmt(summary.get("warning_days", warn_days)),
|
||||
"在售SKU数": _fmt(summary.get("sold_sku_count_30d")),
|
||||
"风险SKU数": _fmt(summary.get("stock_risk_sku_count")),
|
||||
"紧急SKU数": _fmt(summary.get("urgent_sku_count")),
|
||||
"预警SKU数": _fmt(summary.get("warning_sku_count")),
|
||||
}
|
||||
summary_record_id = _create_record(token, args.app_token, args.table_id, summary_record)
|
||||
|
||||
detail_record_ids: List[str] = []
|
||||
for row in details[:top]:
|
||||
fields = {
|
||||
"记录类型": "明细",
|
||||
"批次ID": batch_id,
|
||||
"报表时间": now_text,
|
||||
"统计周期天数": _fmt(days),
|
||||
"补货提前期天数": _fmt(lead_days),
|
||||
"预警阈值天数": _fmt(warn_days),
|
||||
"SKU编码": _fmt(row.get("sku_id")),
|
||||
"SKU名称": _fmt(row.get("sku_name")),
|
||||
"SPU": _fmt(row.get("spu")),
|
||||
"风险等级": _fmt(row.get("stock_risk_level")),
|
||||
"预计缺货天数": _fmt(row.get("estimated_stockout_days")),
|
||||
"建议补货件数": _fmt(row.get("suggest_replenish_qty_to_lead_days")),
|
||||
"可用库存": _fmt(row.get("available_qty")),
|
||||
"近7天发货件数": _fmt(row.get("shipped_qty_7d")),
|
||||
"近30天发货件数": _fmt(row.get("shipped_qty_30d")),
|
||||
"近30天日均发货": _fmt(row.get("avg_daily_shipped_30d")),
|
||||
"渠道": _fmt(row.get("top_shop_channel")),
|
||||
"店铺": _fmt(row.get("top_shop_name")),
|
||||
"采购": _fmt(row.get("purchaser_name")),
|
||||
"运营": _fmt(row.get("operator_name")),
|
||||
"工厂": _fmt(row.get("factory_name")),
|
||||
"仓库": _fmt(row.get("warehouse_name")),
|
||||
}
|
||||
rec_id = _create_record(token, args.app_token, args.table_id, fields)
|
||||
detail_record_ids.append(rec_id)
|
||||
|
||||
print("PUSH_STATUS: success")
|
||||
print(f"TABLE_LINK: {args.table_link}")
|
||||
print(f"BATCH_ID: {batch_id}")
|
||||
print(f"SUMMARY_RECORD_ID: {summary_record_id}")
|
||||
print(f"DETAIL_RECORD_COUNT: {len(detail_record_ids)}")
|
||||
if detail_record_ids:
|
||||
print(f"FIRST_DETAIL_RECORD_ID: {detail_record_ids[0]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as e:
|
||||
print(f"PUSH_STATUS: failed\nERROR: {e}", file=sys.stderr)
|
||||
raise
|
||||
47
test_feishu_bitable_openclaw.sh
Normal file
47
test_feishu_bitable_openclaw.sh
Normal file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP_TOKEN="SZ4ebCKU3a9OdfspetxcSeg1n4g"
|
||||
TABLE_ID="tblzuMy85WiRAUX5"
|
||||
TS="$(date +%F_%H:%M:%S)"
|
||||
MSG1="调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=3。返回记录数量和前3条文本字段。"
|
||||
MSG2="调用 feishu_bitable_create_record,app_token=${APP_TOKEN},table_id=${TABLE_ID},fields={\"文本\":\"OpenClaw联调测试-${TS}\"}。返回新建record_id和写入内容。"
|
||||
MSG3="再次调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=5。返回最近5条的文本字段。"
|
||||
|
||||
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-1 --message "$MSG1" --json --timeout 120 > /tmp/feishu_test_step1.out 2>&1 || true
|
||||
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-2 --message "$MSG2" --json --timeout 120 > /tmp/feishu_test_step2.out 2>&1 || true
|
||||
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-3 --message "$MSG3" --json --timeout 120 > /tmp/feishu_test_step3.out 2>&1 || true
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse(path):
|
||||
raw = Path(path).read_text(encoding="utf-8", errors="ignore").strip()
|
||||
dec = json.JSONDecoder()
|
||||
data = None
|
||||
try:
|
||||
data, _ = dec.raw_decode(raw)
|
||||
except Exception:
|
||||
idx = raw.find('{')
|
||||
while idx != -1:
|
||||
try:
|
||||
data, _ = dec.raw_decode(raw[idx:])
|
||||
break
|
||||
except Exception:
|
||||
idx = raw.find('{', idx + 1)
|
||||
if not isinstance(data, dict):
|
||||
return {"ok": False, "raw": raw[:600]}
|
||||
payloads = data.get("result", {}).get("payloads", [])
|
||||
text = "\n".join(p.get("text", "") for p in payloads if isinstance(p, dict) and p.get("text"))
|
||||
return {"ok": True, "text": text[:2000], "status": data.get("status"), "summary": data.get("summary")}
|
||||
|
||||
for i in (1, 2, 3):
|
||||
r = parse(f"/tmp/feishu_test_step{i}.out")
|
||||
print(f"===== STEP {i} =====")
|
||||
if not r.get("ok"):
|
||||
print("PARSE_FAIL", r.get("raw"))
|
||||
else:
|
||||
print("status=", r.get("status"), "summary=", r.get("summary"))
|
||||
print(r.get("text") or "<empty>")
|
||||
PY
|
||||
25
test_feishu_node.py
Normal file
25
test_feishu_node.py
Normal file
@ -0,0 +1,25 @@
|
||||
import requests
|
||||
|
||||
app_id = 'cli_a9000d2e8678dbde'
|
||||
app_secret = 'E4sZQAEk4kZC1jYXE5JUodr8rtXnnB7W'
|
||||
node = 'RBSxwq8woiQDCUkRZfkcg7x8nMb'
|
||||
|
||||
r = requests.post(
|
||||
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
|
||||
json={'app_id': app_id, 'app_secret': app_secret},
|
||||
timeout=15,
|
||||
)
|
||||
print('token_status=', r.status_code)
|
||||
print('token_body=', r.text)
|
||||
res = r.json()
|
||||
tok = res.get('tenant_access_token')
|
||||
headers = {'Authorization': f'Bearer {tok}'}
|
||||
|
||||
r2 = requests.get(
|
||||
'https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node',
|
||||
params={'token': node},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
print('node_status=', r2.status_code)
|
||||
print('node_body=', r2.text)
|
||||
28
update_agents_feishu_continuous.py
Normal file
28
update_agents_feishu_continuous.py
Normal file
@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
p=Path('/root/.openclaw/workspace/AGENTS.md')
|
||||
text=p.read_text(encoding='utf-8')
|
||||
old='''Required behavior:
|
||||
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
|
||||
- In final reply, always include:
|
||||
1) push status (success/failed)
|
||||
2) target sheet link (the user-provided wiki/base URL)
|
||||
3) written record_id (or updated count)
|
||||
- If write partially fails, return succeeded records and failed reason list separately.
|
||||
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
|
||||
'''
|
||||
new='''Required behavior:
|
||||
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
|
||||
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
|
||||
- If target sheet link is omitted this turn, reuse the most recent Feishu wiki/base link from this conversation; if no link exists, ask one short question for the link.
|
||||
- In final reply, always include:
|
||||
1) push status (success/failed)
|
||||
2) target sheet link (the user-provided wiki/base URL)
|
||||
3) written record_id (or updated count)
|
||||
- If write partially fails, return succeeded records and failed reason list separately.
|
||||
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
|
||||
'''
|
||||
if old not in text:
|
||||
raise SystemExit('target block not found')
|
||||
text=text.replace(old,new,1)
|
||||
p.write_text(text,encoding='utf-8')
|
||||
print('updated',p)
|
||||
26
update_agents_stock_rules.py
Normal file
26
update_agents_stock_rules.py
Normal file
@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
p = Path('/root/.openclaw/workspace/AGENTS.md')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
text = text.replace(
|
||||
'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图',
|
||||
'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存'
|
||||
)
|
||||
text = text.replace(
|
||||
'- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`',
|
||||
'- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`'
|
||||
)
|
||||
text = text.replace(
|
||||
'- SKU编码, SKU名称, 成本价, 材质, 预览图地址',
|
||||
'- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议'
|
||||
)
|
||||
extra = '''- 库存预警(强制):
|
||||
- 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)`
|
||||
- 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急`
|
||||
'''
|
||||
if '库存预警(强制)' not in text:
|
||||
insert_after = '- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)\n'
|
||||
if insert_after in text:
|
||||
text = text.replace(insert_after, insert_after + extra)
|
||||
|
||||
p.write_text(text, encoding='utf-8')
|
||||
print('updated', p)
|
||||
53
update_company_db_tables.py
Normal file
53
update_company_db_tables.py
Normal file
@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
p=Path('/root/.openclaw/workspace/sqlserver/company_db_tool.py')
|
||||
text=p.read_text(encoding='utf-8')
|
||||
old='''BUSINESS_TABLES = [
|
||||
"jst_goods_profit_kpi_items_afs",
|
||||
"jst_sku",
|
||||
"fys_shopInfo",
|
||||
"jst_order_express_details",
|
||||
"fys_SkuDataLibrary_SpuConfig",
|
||||
"op_operManage",
|
||||
"op_TargetManagement",
|
||||
"fys_Ticketing_install",
|
||||
"fys_Ticketing_install_item",
|
||||
"fys_jstFreightCost",
|
||||
"fys_jstFreightCostDetail",
|
||||
"pc_kfChatLog",
|
||||
]
|
||||
'''
|
||||
new='''BUSINESS_TABLES = [
|
||||
"jst_goods_profit_kpi_items_afs",
|
||||
"jst_sku",
|
||||
"fys_shopInfo",
|
||||
"jst_order_express_details",
|
||||
"fys_SkuDataLibrary_SpuConfig",
|
||||
"op_operManage",
|
||||
"op_TargetManagement",
|
||||
"fys_Ticketing_install",
|
||||
"fys_Ticketing_install_item",
|
||||
"fys_jstFreightCost",
|
||||
"fys_jstFreightCostDetail",
|
||||
"pc_kfChatLog",
|
||||
"jst_inventoryInfo",
|
||||
"pm_InventoryMonitor",
|
||||
"pm_stockMonitor",
|
||||
"wp_stock",
|
||||
"erp_wms_stockbiz",
|
||||
]
|
||||
'''
|
||||
if old not in text:
|
||||
raise SystemExit('target block not found')
|
||||
text=text.replace(old,new,1)
|
||||
# also update dimension row if exists
|
||||
needle=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
|
||||
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
|
||||
'''
|
||||
insert=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
|
||||
("库存维度", "可用库存、锁定库存、在途库存、近7/30天发货均值、缺货预警", "jst_inventoryInfo, pm_InventoryMonitor, pm_stockMonitor, wp_stock, erp_wms_stockbiz"),
|
||||
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
|
||||
'''
|
||||
if needle in text:
|
||||
text=text.replace(needle,insert,1)
|
||||
p.write_text(text,encoding='utf-8')
|
||||
print('updated',p)
|
||||
18
update_skill_stock_rules.py
Normal file
18
update_skill_stock_rules.py
Normal file
@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
p = Path('/root/.openclaw/skills/web-task-router/SKILL.md')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
text = text.replace(
|
||||
'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"',
|
||||
'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图", "库存", "缺货", "补货"'
|
||||
)
|
||||
text = text.replace(
|
||||
' 2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`',
|
||||
' 2) Product info / stock-risk query (`SKU/成本/材质/预览图/库存/发货件数/缺货天数`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`'
|
||||
)
|
||||
extra = '- For stock-risk tasks, always include: current stock, daily shipped pieces, estimated stockout days, and whether it is within the 10-day replenishment lead line.\n'
|
||||
if 'For stock-risk tasks' not in text:
|
||||
anchor = ' 5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`\n'
|
||||
if anchor in text:
|
||||
text = text.replace(anchor, anchor + extra)
|
||||
p.write_text(text, encoding='utf-8')
|
||||
print('updated', p)
|
||||
25
update_sql_readme_stock.py
Normal file
25
update_sql_readme_stock.py
Normal file
@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
p = Path('/root/.openclaw/workspace/sqlserver/README_SQL_TEMPLATES.md')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
text = text.replace(
|
||||
'- 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址)',
|
||||
'- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存与缺货预警(成本、材质、预览图、库存、发货件数、预计缺货天数)'
|
||||
)
|
||||
if '- 结果集9:最近N天每日总发货件数(按SKU)' not in text:
|
||||
text = text.replace(
|
||||
' - 结果集8:最近N天售后原因TOP(含来源)\n',
|
||||
' - 结果集8:最近N天售后原因TOP(含来源)\n'
|
||||
' - 结果集9:最近N天每日总发货件数(按SKU)\n'
|
||||
' - 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)\n'
|
||||
)
|
||||
if '15. `sku_stock_risk_report.sql`' not in text:
|
||||
insert = '''
|
||||
15. `sku_stock_risk_report.sql`
|
||||
- 用途:按 SKU 编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
|
||||
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
|
||||
- 参数:修改模板顶部 `@sku` 即可
|
||||
'''
|
||||
text = text.replace('14. `customer-service-analysis`(CLI辅助命令)\n', '14. `customer-service-analysis`(CLI辅助命令)\n' + insert)
|
||||
|
||||
p.write_text(text, encoding='utf-8')
|
||||
print('updated', p)
|
||||
11
update_tools_stock_helper.py
Normal file
11
update_tools_stock_helper.py
Normal file
@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
p = Path('/root/.openclaw/workspace/TOOLS.md')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
text = text.replace(
|
||||
'- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n',
|
||||
'- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n 4) Last N days daily shipped pieces by SKU\n 5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`)\n'
|
||||
)
|
||||
if '- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.' not in text:
|
||||
text += '\n- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.\n'
|
||||
p.write_text(text, encoding='utf-8')
|
||||
print('updated', p)
|
||||
27
update_websites_stock.py
Normal file
27
update_websites_stock.py
Normal file
@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
p=Path('/root/.openclaw/workspace/websites.yaml')
|
||||
text=p.read_text(encoding='utf-8')
|
||||
for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']:
|
||||
if kw not in text:
|
||||
text=text.replace(' - 上周\n force_site_id: company-sales-db-analysis\n', ' - 上周\n'+kw+' force_site_id: company-sales-db-analysis\n')
|
||||
|
||||
for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']:
|
||||
if kw not in text:
|
||||
text=text.replace(' - 预览图\n login_required: false\n', ' - 预览图\n'+kw+' login_required: false\n')
|
||||
|
||||
add_tables = [' - jst_inventoryInfo\n',' - pm_InventoryMonitor\n',' - pm_stockMonitor\n',' - wp_stock\n']
|
||||
for t in add_tables:
|
||||
if t not in text:
|
||||
text=text.replace(' - pc_kfChatLog\n', ' - pc_kfChatLog\n'+t)
|
||||
|
||||
line=' - 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`\n'
|
||||
newline=' - 产品信息/库存查询(SKU编码/成本/材质/预览图/库存/缺货天数)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`\n'
|
||||
if line in text:
|
||||
text=text.replace(line,newline)
|
||||
|
||||
stock_rule=' - 库存预警口径:输出当前库存、近30天日均发货件数、预计缺货天数;若预计缺货天数<=10天,必须标记补货紧急。\n'
|
||||
if stock_rule not in text:
|
||||
text=text.replace(' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n', ' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n'+stock_rule)
|
||||
|
||||
p.write_text(text,encoding='utf-8')
|
||||
print('updated',p)
|
||||
BIN
xhs-login-qrcode.png
Normal file
BIN
xhs-login-qrcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
xhs-main.zip
Normal file
BIN
xhs-main.zip
Normal file
Binary file not shown.
BIN
xiaohongshu-mcp-main/.DS_Store
vendored
Normal file
BIN
xiaohongshu-mcp-main/.DS_Store
vendored
Normal file
Binary file not shown.
212
xiaohongshu-mcp-main/.all-contributorsrc
Normal file
212
xiaohongshu-mcp-main/.all-contributorsrc
Normal file
@ -0,0 +1,212 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"commitType": "docs",
|
||||
"commitConvention": "angular",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "xpzouying",
|
||||
"name": "zy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3946563?v=4",
|
||||
"profile": "https://haha.ai",
|
||||
"contributions": [
|
||||
"code",
|
||||
"ideas",
|
||||
"doc",
|
||||
"design",
|
||||
"maintenance",
|
||||
"infra",
|
||||
"review"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "esperyong",
|
||||
"name": "clearwater",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1271815?v=4",
|
||||
"profile": "http://www.hwbuluo.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "laryzhong",
|
||||
"name": "Zhongpeng",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/47939471?v=4",
|
||||
"profile": "https://github.com/laryzhong",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DTDucas",
|
||||
"name": "Duong Tran",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/105262836?v=4",
|
||||
"profile": "https://github.com/DTDucas",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Angiin",
|
||||
"name": "Angiin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/17389304?v=4",
|
||||
"profile": "https://github.com/Angiin",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "muhenan",
|
||||
"name": "Henan Mu",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/43441941?v=4",
|
||||
"profile": "https://github.com/muhenan",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "chengazhen",
|
||||
"name": "Journey",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/52627267?v=4",
|
||||
"profile": "https://github.com/chengazhen",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "eveyuyi",
|
||||
"name": "Eve Yu",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/69026872?v=4",
|
||||
"profile": "https://github.com/eveyuyi",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "CooperGuo",
|
||||
"name": "CooperGuo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/183056602?v=4",
|
||||
"profile": "https://github.com/CooperGuo",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "BiboyQG",
|
||||
"name": "Banghao Chi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/125724218?v=4",
|
||||
"profile": "https://biboyqg.github.io/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "varz1",
|
||||
"name": "varz1",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/60377372?v=4",
|
||||
"profile": "https://github.com/varz1",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Meloyg",
|
||||
"name": "Melo Y Guan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/62586556?v=4",
|
||||
"profile": "https://google.meloguan.site",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lmxdawn",
|
||||
"name": "lmxdawn",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21293193?v=4",
|
||||
"profile": "https://github.com/lmxdawn",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "haikow",
|
||||
"name": "haikow",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/22428382?v=4",
|
||||
"profile": "https://github.com/haikow",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "a67793581",
|
||||
"name": "Carlo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4",
|
||||
"profile": "https://carlo-blog.aiju.fun/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hrz394943230",
|
||||
"name": "hrz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/28583005?v=4",
|
||||
"profile": "https://github.com/hrz394943230",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ctrlz526",
|
||||
"name": "Ctrlz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/143257420?v=4",
|
||||
"profile": "https://github.com/ctrlz526",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "flippancy",
|
||||
"name": "flippancy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6467703?v=4",
|
||||
"profile": "https://github.com/flippancy",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Infinityay",
|
||||
"name": "Yuhang Lu",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/103165980?v=4",
|
||||
"profile": "https://github.com/Infinityay",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "triepod-ai",
|
||||
"name": "Bryan Thompson",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/199543909?v=4",
|
||||
"profile": "https://triepod.ai",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "tanxxjun321",
|
||||
"name": "tan jun",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7806992?v=4",
|
||||
"profile": "http://www.megvii.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"skipCi": true,
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"projectName": "xiaohongshu-mcp",
|
||||
"projectOwner": "xpzouying"
|
||||
}
|
||||
8
xiaohongshu-mcp-main/.cursor/mcp.json
Normal file
8
xiaohongshu-mcp-main/.cursor/mcp.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"xiaohongshu-mcp": {
|
||||
"url": "http://localhost:18060/mcp",
|
||||
"description": "小红书内容发布服务 - MCP Streamable HTTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
xiaohongshu-mcp-main/.dockerignore
Normal file
16
xiaohongshu-mcp-main/.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
.claude
|
||||
.cursor
|
||||
.github
|
||||
**/*.log
|
||||
bin
|
||||
dist
|
||||
vendor
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker
|
||||
.DS_Store
|
||||
|
||||
cookies.json
|
||||
1
xiaohongshu-mcp-main/.github/CODEOWNERS
vendored
Normal file
1
xiaohongshu-mcp-main/.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
||||
* @xpzouying
|
||||
2
xiaohongshu-mcp-main/.github/FUNDING.yml
vendored
Normal file
2
xiaohongshu-mcp-main/.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- "https://github.com/xpzouying/xiaohongshu-mcp#赞赏支持"
|
||||
55
xiaohongshu-mcp-main/.github/workflows/aliyun-docker-release.yml
vendored
Normal file
55
xiaohongshu-mcp-main/.github/workflows/aliyun-docker-release.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Aliyun Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
default: 'v1.0.0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Aliyun Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com
|
||||
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image (AMD64)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}
|
||||
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Docker image (ARM64)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.arm64
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
tags: |
|
||||
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64
|
||||
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest-arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
54
xiaohongshu-mcp-main/.github/workflows/docker-release.yml
vendored
Normal file
54
xiaohongshu-mcp-main/.github/workflows/docker-release.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
default: 'v1.0.0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: xpzouying
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image (AMD64)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}
|
||||
xpzouying/xiaohongshu-mcp:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Docker image (ARM64)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.arm64
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
tags: |
|
||||
xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64
|
||||
xpzouying/xiaohongshu-mcp:latest-arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
158
xiaohongshu-mcp-main/.github/workflows/release.yml
vendored
Normal file
158
xiaohongshu-mcp-main/.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,158 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'README_EN.md'
|
||||
- 'CLAUDE.md'
|
||||
- '.all-contributorsrc'
|
||||
- '.gitignore'
|
||||
- '.dockerignore'
|
||||
- 'Dockerfile'
|
||||
- '.claude/**'
|
||||
- '.cursor/**'
|
||||
- '.github/**'
|
||||
- '.vscode/**'
|
||||
- 'assets/**'
|
||||
- 'configs/**'
|
||||
- 'cookies/**'
|
||||
- 'docker/**'
|
||||
- 'deploy/**'
|
||||
- 'docs/**'
|
||||
- 'donate/**'
|
||||
- 'examples/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# 只在推送到 main 时运行,或手动触发
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Generate release name
|
||||
id: version
|
||||
run: |
|
||||
TIMESTAMP=$(date +%Y.%m.%d.%H%M)
|
||||
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
VERSION="v${TIMESTAMP}-${COMMIT_SHA}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Generated version: ${VERSION}"
|
||||
|
||||
- name: Build for multiple platforms
|
||||
run: |
|
||||
# 主程序构建
|
||||
# 禁用 CGO 以生成静态链接的二进制文件,避免 GLIBC 依赖问题
|
||||
# macOS ARM64 (Apple Silicon)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 .
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login
|
||||
|
||||
# macOS Intel
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 .
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login
|
||||
|
||||
# Windows x64
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe .
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login
|
||||
|
||||
# Linux x64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login
|
||||
|
||||
- name: Package binaries
|
||||
run: |
|
||||
# 创建压缩包
|
||||
# macOS ARM64
|
||||
tar czf xiaohongshu-mcp-darwin-arm64.tar.gz xiaohongshu-mcp-darwin-arm64 xiaohongshu-login-darwin-arm64
|
||||
|
||||
# macOS Intel
|
||||
tar czf xiaohongshu-mcp-darwin-amd64.tar.gz xiaohongshu-mcp-darwin-amd64 xiaohongshu-login-darwin-amd64
|
||||
|
||||
# Windows x64
|
||||
zip xiaohongshu-mcp-windows-amd64.zip xiaohongshu-mcp-windows-amd64.exe xiaohongshu-login-windows-amd64.exe
|
||||
|
||||
# Linux x64
|
||||
tar czf xiaohongshu-mcp-linux-amd64.tar.gz xiaohongshu-mcp-linux-amd64 xiaohongshu-login-linux-amd64
|
||||
|
||||
- name: Clean up old releases
|
||||
run: |
|
||||
# 获取所有自动构建的 releases (v开头的时间戳格式)
|
||||
RELEASES=$(gh release list --limit 100 | grep -E '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}-' | awk '{print $3}' | tail -n +11)
|
||||
|
||||
# 删除超过 10 个的旧 releases 和对应的 tags
|
||||
for release in $RELEASES; do
|
||||
echo "Deleting old release: $release"
|
||||
gh release delete "$release" --yes --cleanup-tag
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: Release ${{ steps.version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
body: |
|
||||
## 🔧 自动构建版本
|
||||
|
||||
**注意:这是自动构建的预发布版本,用于测试。正式版本请等待手动发布。**
|
||||
|
||||
### 📦 下载说明
|
||||
|
||||
选择适合您系统的压缩包下载:
|
||||
- **macOS Apple Silicon (M1/M2/M3)**: `xiaohongshu-mcp-darwin-arm64.tar.gz`
|
||||
- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64.tar.gz`
|
||||
- **Windows x64**: `xiaohongshu-mcp-windows-amd64.zip`
|
||||
- **Linux x64**: `xiaohongshu-mcp-linux-amd64.tar.gz`
|
||||
|
||||
每个压缩包包含:
|
||||
- `xiaohongshu-mcp-*`: MCP 服务主程序
|
||||
- `xiaohongshu-login-*`: 登录工具
|
||||
|
||||
### 🔧 使用方法
|
||||
|
||||
```bash
|
||||
# 1. 解压文件(macOS/Linux)
|
||||
tar xzf xiaohongshu-mcp-darwin-arm64.tar.gz
|
||||
|
||||
# 或 Windows
|
||||
# 解压 xiaohongshu-mcp-windows-amd64.zip
|
||||
|
||||
# 2. 运行登录工具
|
||||
./xiaohongshu-login-darwin-arm64
|
||||
|
||||
# 3. 启动 MCP 服务
|
||||
./xiaohongshu-mcp-darwin-arm64
|
||||
```
|
||||
|
||||
### 🐳 Docker 镜像
|
||||
|
||||
Docker 镜像需要手动触发构建,请到 Actions 页面运行 "Docker Release" workflow。
|
||||
|
||||
### 📊 构建信息
|
||||
|
||||
- **Commit**: ${{ github.sha }}
|
||||
- **Branch**: main
|
||||
- **Build Time**: ${{ steps.version.outputs.version }}
|
||||
files: |
|
||||
xiaohongshu-mcp-darwin-arm64.tar.gz
|
||||
xiaohongshu-mcp-darwin-amd64.tar.gz
|
||||
xiaohongshu-mcp-windows-amd64.zip
|
||||
xiaohongshu-mcp-linux-amd64.tar.gz
|
||||
153
xiaohongshu-mcp-main/.github/workflows/tag-release.yml
vendored
Normal file
153
xiaohongshu-mcp-main/.github/workflows/tag-release.yml
vendored
Normal file
@ -0,0 +1,153 @@
|
||||
name: Tag and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
release_notes:
|
||||
description: 'Release notes (optional)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
tag-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?$ ]]; then
|
||||
echo "Error: Version must follow semantic versioning (e.g., v1.0.0, v1.0.0-beta.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if tag already exists
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version $VERSION is valid"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Build for multiple platforms
|
||||
run: |
|
||||
# 主程序构建
|
||||
# macOS ARM64 (Apple Silicon)
|
||||
GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 .
|
||||
GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login
|
||||
|
||||
# macOS Intel
|
||||
GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login
|
||||
|
||||
# Windows x64
|
||||
GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe .
|
||||
GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login
|
||||
|
||||
# Linux x64
|
||||
GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 .
|
||||
GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get the previous tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found, including all commits"
|
||||
COMMITS=$(git log --oneline --format="- %s (%h)" | head -20)
|
||||
else
|
||||
echo "Generating changelog from $PREV_TAG to HEAD"
|
||||
COMMITS=$(git log $PREV_TAG..HEAD --oneline --format="- %s (%h)")
|
||||
fi
|
||||
|
||||
# Save to output
|
||||
{
|
||||
echo "commits<<EOF"
|
||||
echo "$COMMITS"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create tag and release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
name: Release ${{ github.event.inputs.version }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.event.inputs.version, '-') }}
|
||||
body: |
|
||||
## 🚀 新版本发布: ${{ github.event.inputs.version }}
|
||||
|
||||
${{ github.event.inputs.release_notes }}
|
||||
|
||||
### 📋 更新内容
|
||||
|
||||
${{ steps.changelog.outputs.commits }}
|
||||
|
||||
---
|
||||
|
||||
### 📦 下载说明
|
||||
|
||||
**主程序(MCP 服务):**
|
||||
- **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64`
|
||||
- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64`
|
||||
- **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe`
|
||||
- **Linux x64**: `xiaohongshu-mcp-linux-amd64`
|
||||
|
||||
**登录工具:**
|
||||
- **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64`
|
||||
- **macOS Intel**: `xiaohongshu-login-darwin-amd64`
|
||||
- **Windows x64**: `xiaohongshu-login-windows-amd64.exe`
|
||||
- **Linux x64**: `xiaohongshu-login-linux-amd64`
|
||||
|
||||
### 🔧 使用方法
|
||||
|
||||
```bash
|
||||
# 1. 首先运行登录工具获取 cookie
|
||||
./xiaohongshu-login-darwin-arm64
|
||||
|
||||
# 2. 然后启动 MCP 服务
|
||||
./xiaohongshu-mcp-darwin-arm64
|
||||
|
||||
# 或指定参数
|
||||
./xiaohongshu-mcp-darwin-arm64 -headless=false
|
||||
```
|
||||
|
||||
### ⚠️ 注意事项
|
||||
|
||||
- 首次运行时会自动下载无头浏览器(约 150MB),请确保网络连接正常
|
||||
- 后续运行无需重复下载浏览器
|
||||
- 登录工具生成的 cookie 保存在 `~/.xiaohongshu/cookies.json`
|
||||
|
||||
### 📊 构建信息
|
||||
|
||||
- **Commit**: ${{ github.sha }}
|
||||
- **Go Version**: 1.24
|
||||
- **Build Time**: ${{ github.event.repository.updated_at }}
|
||||
files: |
|
||||
xiaohongshu-mcp-darwin-arm64
|
||||
xiaohongshu-mcp-darwin-amd64
|
||||
xiaohongshu-mcp-windows-amd64.exe
|
||||
xiaohongshu-mcp-linux-amd64
|
||||
xiaohongshu-login-darwin-arm64
|
||||
xiaohongshu-login-darwin-amd64
|
||||
xiaohongshu-login-windows-amd64.exe
|
||||
xiaohongshu-login-linux-amd64
|
||||
44
xiaohongshu-mcp-main/.gitignore
vendored
Normal file
44
xiaohongshu-mcp-main/.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
.idea
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Code coverage profiles and other test artifacts
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
.claude/
|
||||
|
||||
# Build artifacts
|
||||
xiaohongshu-mcp
|
||||
|
||||
# Test scripts
|
||||
test_*.sh
|
||||
|
||||
# Cookies files (contain sensitive login information)
|
||||
cookies.json
|
||||
9
xiaohongshu-mcp-main/.vscode/mcp.json
vendored
Normal file
9
xiaohongshu-mcp-main/.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"servers": {
|
||||
"xiaohongshu-mcp": {
|
||||
"url": "http://localhost:18060/mcp",
|
||||
"type": "http"
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
7
xiaohongshu-mcp-main/CLAUDE.md
Normal file
7
xiaohongshu-mcp-main/CLAUDE.md
Normal file
@ -0,0 +1,7 @@
|
||||
- 要求每次修改完后,需要帮我格式化 Go 源码文件.
|
||||
- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除.
|
||||
- 所有的feature变更,都需要使用分支进行开发.
|
||||
- 在我未同意之前, 你不能推送到远程.
|
||||
- 我需要: 1.本地 review; 2.远程 PR review.
|
||||
- 不要过度设计, 保持代码的简洁和易读.
|
||||
- 使用中文注释,一定要简洁明了.专业名词可以用英文.
|
||||
141
xiaohongshu-mcp-main/DONATIONS.md
Normal file
141
xiaohongshu-mcp-main/DONATIONS.md
Normal file
@ -0,0 +1,141 @@
|
||||
# 赞赏与公益捐赠公开账本
|
||||
|
||||
本项目的所有赞赏,将全部用于公益捐赠。
|
||||
|
||||
> 本页按月公开记录:收到的赞赏(默认匿名或使用对方指定昵称)、对应捐出、以及捐赠凭证截图(已脱敏)。
|
||||
> 如需更正/撤回署名,请开 Issue 或通过邮箱联系。
|
||||
|
||||
## 摘要
|
||||
|
||||
- 累计收到赞赏:¥ 969.95
|
||||
- 累计捐赠:¥ 1300
|
||||
- 最近更新时间:2026-02-05
|
||||
|
||||
---
|
||||
|
||||
## 维护说明
|
||||
|
||||
- **隐私**:默认匿名展示;仅在赞助者明确授权时展示昵称。请在截图中打码/涂抹交易号、手机号、邮箱、二维码关键元素等敏感信息。
|
||||
- **更正机制**:如有遗漏或需要修改,请开 Issue;所有更动保留在 Git 历史中。
|
||||
|
||||
---
|
||||
|
||||
## 月度明细
|
||||
|
||||
### 2026-01
|
||||
|
||||
**本月小结**
|
||||
|
||||
- 收到赞赏合计:¥ 99.98
|
||||
- 捐出合计:¥ 200.00
|
||||
|
||||
**收到的赞赏**
|
||||
| 日期 | 昵称 | 金额 | 备注 |
|
||||
|------------|-----:|-----:|------|
|
||||
| 2026-01-02 | K91431 | 49.99 | 赞赏码 |
|
||||
| 2026-01-05 | Yancy | 49.99 | 赞赏码 |
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img height="400" alt="donation-2026-01" src="https://github.com/user-attachments/assets/ab6eae5e-9e8a-496d-94aa-d3f0c8a1cfc1" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
### 2025-12
|
||||
|
||||
**本月小结**
|
||||
|
||||
- 收到赞赏合计:¥ 440.09
|
||||
- 捐出合计:¥ 500.00
|
||||
|
||||
**收到的赞赏**
|
||||
| 日期 | 昵称 | 金额 | 备注 |
|
||||
|------------|-----:|-----:|------|
|
||||
| 2025-12-03 | 源 | 39.90 | 微信红包 |
|
||||
| 2025-12-07 | 来自于微信群的小爷 | 29.99 | 赞赏码 |
|
||||
| 2025-12-08 | 来自于微信群的小爷 | 29.99 | 赞赏码 |
|
||||
| 2025-12-08 | 无名大侠 | 9.99 | 赞赏码 |
|
||||
| 2025-12-10 | 许掌柜 | 100.00 | 微信红包 |
|
||||
| 2025-12-11 | matheasyer | 10.00 | 支付宝 |
|
||||
| 2025-12-18 | 来自于微信群的小爷 | 29.99 | 微信红包 |
|
||||
| 2025-12-19 | 查狸尼克 | 20.26 | 赞赏码 |
|
||||
| 2025-12-19 | 无名大侠 | 49.99 | 赞赏码 |
|
||||
| 2025-12-21 | 陈懂 | 50.00 | 赞赏码,for 泡芙小姐 |
|
||||
| 2025-12-22 | G仔 | 49.99 | 赞赏码 |
|
||||
| 2025-12-25 | 未来可期 | 19.99 | 赞赏码 |
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img height="400" alt="PixPin_2026-01-01_20-45-48" src="https://github.com/user-attachments/assets/b0d9721e-c250-4df3-a993-fda5c4d62e3f" /></td>
|
||||
<td><img height="400" alt="PixPin_2026-01-01_20-46-03" src="https://github.com/user-attachments/assets/bc2c8076-1ad4-41b6-b8f7-d2cdc1b5fa87" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
### 2025-11
|
||||
|
||||
**本月小结**
|
||||
|
||||
- 收到赞赏合计:¥ 249.96
|
||||
- 捐出合计:¥ 400.00。守望相助,驰援香江:为香港大浦火灾同胞筹集善款!
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img height="400" alt="PixPin_2025-10-26_21-34-08" src="https://github.com/user-attachments/assets/2dc52a01-d14a-4eec-961f-406f1ef91889" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
**收到的赞赏**
|
||||
| 日期 | 昵称 | 金额 | 备注 |
|
||||
|------------|-----:|-----:|------|
|
||||
| 2025-11-05 | 勇敢的心 | 9.99 | 赞赏码 |
|
||||
| 2025-11-10 | Sijin Yang | 99.99 | 赞赏码 |
|
||||
| 2025-11-17 | cym | 29.99 | 赞赏码 |
|
||||
| 2025-11-26 | 一虎君 | 10.00 | 赞赏码 |
|
||||
| 2025-11-26 | Sijin Yang | 99.99 | 赞赏码 |
|
||||
|
||||
### 2025-10
|
||||
|
||||
**本月小结**
|
||||
|
||||
- 收到赞赏合计:¥ 109.93
|
||||
- 捐出合计:¥ 200.00。 9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img height="400" alt="PixPin_2025-10-26_21-34-08" src="https://github.com/user-attachments/assets/8329275c-a328-410e-8744-9bc267661c31" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
**收到的赞赏**
|
||||
| 日期 | 昵称 | 金额 | 备注 |
|
||||
|------------|-----:|-----:|------|
|
||||
| 2025-10-11 | Sijin Yang | 29.99 | 赞赏码 |
|
||||
| 2025-10-13 | Sijin Yang | 29.99 | 赞赏码 |
|
||||
| 2025-10-16 | RESOLUTION | 9.99 | 赞赏码 |
|
||||
| 2025-10-17 | Sijin Yang | 9.99 | 赞赏码 |
|
||||
| 2025-10-19 | 无名大侠 | 9.99 | 赞赏码 |
|
||||
| 2025-10-22 | Sijin Yang | 9.99 | 赞赏码 |
|
||||
| 2025-10-22 | 无名大侠 | 9.99 | 赞赏码 |
|
||||
|
||||
### 2025-09
|
||||
|
||||
**本月小结**
|
||||
|
||||
- 收到赞赏合计:¥ 69.99
|
||||
- 捐出合计:9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。
|
||||
|
||||
**收到的赞赏**
|
||||
| 日期 | 昵称 | 金额 | 备注 |
|
||||
|------------|-----:|-----:|------|
|
||||
| 2025-09-23 | 米爸 | 50.00 | 微信红包 |
|
||||
| 2025-09-27 | 麦子 | 19.99 | 赞赏码 |
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
- 2025-10-26:初始化赞赏记录。汇总 2025 年 9 月、10 月份的赞赏,捐赠给「春蕾计划她们想上学」。
|
||||
86
xiaohongshu-mcp-main/Dockerfile
Normal file
86
xiaohongshu-mcp-main/Dockerfile
Normal file
@ -0,0 +1,86 @@
|
||||
# ---- build stage ----
|
||||
FROM golang:1.24 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
# 配置 Go 模块代理为国内源
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV GOSUMDB=sum.golang.google.cn
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app .
|
||||
|
||||
# ---- run stage ----
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1. 先安装必要工具,然后配置阿里云镜像源
|
||||
RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \
|
||||
sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \
|
||||
sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list
|
||||
|
||||
# 2. 添加 Google Chrome APT 源并安装 Chrome(更稳定的无头浏览器)
|
||||
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list
|
||||
|
||||
# 3. 安装 Google Chrome + 依赖(无头模式运行 rod)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libc6 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgbm1 \
|
||||
libgcc1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libstdc++6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxcursor1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
lsb-release \
|
||||
wget \
|
||||
xdg-utils \
|
||||
google-chrome-stable \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /out/app .
|
||||
|
||||
# 4. 创建共享目录并设置权限
|
||||
RUN mkdir -p /app/images && \
|
||||
chmod 777 /app/images
|
||||
|
||||
# 5. 设置默认 Chrome 路径(rod 会用)
|
||||
ENV ROD_BROWSER_BIN=/usr/bin/google-chrome
|
||||
|
||||
EXPOSE 18060
|
||||
|
||||
CMD ["./app"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user