Commit 574904c8 authored by allen.wang's avatar allen.wang

feat:init

parent 6cedbcf0
Pipeline #636 failed with stages
# VIP Report Skill Bundle
这是一套围绕 VIP 月报模板的本地 skill 骨架。
已落地内容:
- 总 skill:`vip-report`
- 子 skill:
- `overview`
- `data-sync`
- `presentation`
- 基础脚本:
- 模板 manifest 提取
- PowerPoint COM shape inventory
- 基线渲染
- `pptx` 包级比对
当前生成目标不是“重新画一份 PPT”,而是:
- 保留原始模板
- 只替换模板中的现有内容
- 每次生成都和基准模板做对比
## 迁移到新电脑 Checklist
1. 准备运行环境(Windows)
- 安装 `Python 3.10+`(建议 3.11)。
- 安装 `Node.js 18+`,确保命令行可用 `npx`
- 安装 `Microsoft PowerPoint``render.mode=com` 依赖 COM 自动化)。
- 确认网络可访问 `http://tableau.charleskeith.cn`
2. 复制目录与文件
- 复制 skill 目录:`C:\Users\<你的用户名>\.codex\skills\vip-report`
- 复制配置目录:`C:\Users\<你的用户名>\.codex\vip-report`
- 准备模板文件:`Report.pptx`(路径可自定义,需写入 `config.yaml`)。
- 准备来源映射:`slide-source-map.yaml`(路径需写入 `config.yaml`)。
3. 修改配置(必须)
- 打开 `C:\Users\<你的用户名>\.codex\vip-report\config.yaml`
- 使用 `config.yaml.example` 作为参考,至少检查:
- `tableau.username` / `tableau.password`
- `mysql.username` / `mysql.password`(若该页用到数据库)
- `paths.template_pptx`
- `paths.slide_source_map`
- `paths.workdir`
- `render.mode`(默认 `com`
- `report.month_cn` / `report.year` / `report.compare_year`(按当期月报设置)
4. 安装 Python 依赖(首次)
- 执行:`python -m pip install pillow pyyaml`
5. 最小化验证(先只跑 S02)
- 执行:
- `powershell -ExecutionPolicy Bypass -File C:\Users\<你的用户名>\.codex\skills\vip-report\bin\vip-report-monthly-sales-sync.ps1 -ConfigPath C:\Users\<你的用户名>\.codex\vip-report\config.yaml -Slides S02 -ReportMonth 二月 -ReportYear 2026 -CompareYear 2025 -Render`
- 产物应生成在 `config.yaml -> paths.workdir` 指向目录下。
6. 常见问题
- 报错 `PowerPoint.Application` 创建失败:通常是未安装 Office 或权限不足。
- 报错 `npx` 不存在:Node.js 未安装或 PATH 未生效。
- Tableau 偶发 `get_workbook` 空对象:属于页面初始化时序问题,脚本已内置重试;仍失败时直接重跑同命令一次。
---
name: vip-report
description: 协调 VIP 月报所有页的来源与渲染,通过固定配置与模板确保生成版与基线完全一致。
---
# VIP Report
## 概览
这个总 skill 串联 VIP 月报的数据采集、素材生成、模板替换、比对验证四步,确保最终 `Report.pptx` 一模一样。
## 数据来源约定
- 所有页的来源映射记录在 `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`,按 slide_code 映射到 Tableau view、MySQL 表或第三方系统。
- 依赖 Tableau (`tableau.charleskeith.cn`) 时统一走 `config.yaml` 里的账号(目前是 `ec_user01`),优先尝试下载/导出,若必须截屏则按照脚本裁切。
- 某些页面会引用 `vip.com`,凭据不驻留仓库;调用 `config.yaml` 中的 `vip.login_endpoint` 让第三方系统自动处理登录。
- 需要数据库的页面使用测试库 `ckc_cep_db_test``config.yaml``mysql` 段),会参考 `s11-source-validation.md``s11-sql-hypothesis.sql` 等文件保持口径。
## 关键路径
- 配置文件:`C:\Users\niuniu\.codex\vip-report\config.yaml`
- 配置样例:`C:\Users\niuniu\.codex\vip-report\config.yaml.example`
- 模板 PPT:`C:\Users\niuniu\Desktop\Report.pptx`
- slide 来源表:`C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
- 素材与输出目录:`C:\workspace\cursor\output\vip-report`
## 渲染策略
- 目前 Windows 可以成功创建 `PowerPoint.Application`,建议优先用 COM 改写模板(如 `render_template_com.ps1`)。
- `OpenXML` 只在无法运行 COM 或需包级比较时作为补充手段。
- 所有替换完成后必须运行 `compare_pptx.py``render-ops.*.json` 也可辅助)确认差异限定在预期的 `ppt/media/image*.png``ppt/slides/slide*.xml` 中。
## 子技能与流程
- `/vip-report overview`:整理 `slide-source-map.yaml``shape-inventory.json``template-manifest.json`,确认每页 shape_id/名称。
- `/vip-report data-sync`:按 slide 逐个源头从 Tableau 或 SQL 拉数据并生成 `render-ops.*.json`
- `/vip-report presentation`:调用 `vip-report-render.ps1` 等脚本把素材替回 `Report.pptx` 并输出比对结果。
- 当前拆出的专题 skill:`/vip-report monthly-sales``/vip-report inventory-monthly``/vip-report top-products``/vip-report campaign-s11``/vip-report warehouse-100060`,它们分别对应 `slide-source-map.yaml` 中 S02-S03、S04-S10、S11、S13 等页的来源。
## 辅助命令
- 生成模板 manifest(用于审查 slide 结构):`powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-manifest.ps1`
- 生成 shape inventory(查 shape_id/名称):`powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-inventory.ps1`
- 生成基线副本与比对(验证模板/生成文件差异):`powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-baseline.ps1`
- 生成 S02/S03 素材并渲染:`powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-monthly-sales-sync.ps1 -Render`
## 备注
- Tableau 登录凭据只保存在 `config.yaml`,请勿复制到技能文档。
- vip.com 登录(若展开)只需配置 `vip.login_endpoint` 让第三方服务处理。
- 每次渲染完成后务必与模板做 `compare_pptx.py` 比较,确认差异仅限预期替换的图片和 `docProps/*``ppt/slides/*.xml`
param(
[string]$TemplatePath = "C:\Users\niuniu\Desktop\Report.pptx",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-baseline.pptx",
[string]$ComparePath = "C:\workspace\cursor\output\vip-report\baseline-compare.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
python "$root\scripts\render_baseline.py" "$TemplatePath" "$OutputPath"
python "$root\scripts\compare_pptx.py" "$TemplatePath" "$OutputPath" --output "$ComparePath"
Get-Content -LiteralPath $ComparePath -Encoding UTF8
param(
[string]$ConfigPath = "C:\Users\niuniu\.codex\vip-report\config.yaml",
[string]$Slides = "S04,S05,S06,S07,S08",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$opsPath = "C:\workspace\cursor\output\vip-report\render-ops.inventory-monthly.live.json"
$pythonArgs = @(
"$root\scripts\sync_inventory_monthly_assets.py",
"--config", "$ConfigPath",
"--slides", "$Slides"
)
if ($ReportMonth) {
$pythonArgs += @("--report-month", "$ReportMonth")
}
if ($ReportYear -gt 0) {
$pythonArgs += @("--report-year", "$ReportYear")
}
if ($CompareYear -gt 0) {
$pythonArgs += @("--compare-year", "$CompareYear")
}
python @pythonArgs
Write-Output $opsPath
param(
[string]$TemplatePath = "C:\Users\niuniu\Desktop\Report.pptx",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\shape-inventory.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
powershell -ExecutionPolicy Bypass -File "$root\scripts\inspect_template_inventory.ps1" -TemplatePath "$TemplatePath" -OutputPath "$OutputPath"
Get-Content -LiteralPath $OutputPath -Encoding UTF8 | Select-Object -First 80
param(
[string]$UnpackedRoot = "C:\workspace\cursor\output\vip-report\report-unpacked",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\template-manifest.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
python "$root\scripts\extract_pptx_manifest.py" "$UnpackedRoot" --output "$OutputPath"
Get-Content -LiteralPath $OutputPath -Encoding UTF8 | Select-Object -First 80
param(
[string]$ConfigPath = "C:\Users\niuniu\.codex\vip-report\config.yaml",
[string]$Slides = "S02,S03",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[switch]$Render,
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-monthly-sales-live.pptx",
[string]$CompareOutputPath = "C:\workspace\cursor\output\vip-report\generated-monthly-sales-live.compare.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$workdir = "C:\workspace\cursor"
$opsPath = "C:\workspace\cursor\output\vip-report\render-ops.monthly-sales.live.json"
$templatePath = "C:\Users\niuniu\Desktop\Report.pptx"
# Forward optional report-period args; config.yaml defaults are used when omitted.
$pythonArgs = @(
"$root\scripts\sync_monthly_sales_assets.py",
"--config", "$ConfigPath",
"--slides", "$Slides"
)
if ($ReportMonth) {
$pythonArgs += @("--report-month", "$ReportMonth")
}
if ($ReportYear -gt 0) {
$pythonArgs += @("--report-year", "$ReportYear")
}
if ($CompareYear -gt 0) {
$pythonArgs += @("--compare-year", "$CompareYear")
}
python @pythonArgs
if ($Render) {
powershell -ExecutionPolicy Bypass -File "$root\bin\vip-report-render.ps1" -TemplatePath "$templatePath" -OutputPath "$OutputPath" -OperationsPath "$opsPath" | Out-Null
python "$root\scripts\compare_pptx.py" "$templatePath" "$OutputPath" --output "$CompareOutputPath"
Write-Output $OutputPath
Write-Output $CompareOutputPath
} else {
Write-Output $opsPath
}
param(
[string]$TemplatePath = "C:\Users\niuniu\Desktop\Report.pptx",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-from-template.pptx",
[string]$OperationsPath = ""
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
powershell -ExecutionPolicy Bypass -File "$root\scripts\render_template_com.ps1" -TemplatePath "$TemplatePath" -OutputPath "$OutputPath" -OperationsPath "$OperationsPath"
param(
[string]$ConfigPath = "C:\Users\niuniu\.codex\vip-report\config.yaml",
[string]$Slides = "S02,S03,S04,S05,S06,S07,S08,S09,S10,S13",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[switch]$Render,
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-tableau-all.pptx",
[string]$CompareOutputPath = "C:\workspace\cursor\output\vip-report\generated-tableau-all.compare.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$templatePath = "C:\Users\niuniu\Desktop\Report.pptx"
$mergedOpsPath = "C:\workspace\cursor\output\vip-report\render-ops.tableau.all.live.json"
function Resolve-GroupSlides {
param(
[string[]]$RequestedSlides,
[string[]]$GroupSlides
)
# 按 GroupSlides 的既定顺序过滤,保证输出稳定,避免顺序导致后续差异。
$resolved = ""
foreach ($slide in $GroupSlides) {
if ($RequestedSlides -contains $slide) {
if ($resolved) {
$resolved += ","
}
$resolved += $slide
}
}
return $resolved
}
function Invoke-SyncScript {
param(
[string]$ScriptPath,
[string]$SyncSlides
)
if (-not $SyncSlides) {
return
}
$invokeParams = @{
ConfigPath = $ConfigPath
Slides = $SyncSlides
}
if ($ReportMonth) {
$invokeParams.ReportMonth = $ReportMonth
}
if ($ReportYear -gt 0) {
$invokeParams.ReportYear = $ReportYear
}
if ($CompareYear -gt 0) {
$invokeParams.CompareYear = $CompareYear
}
$global:LASTEXITCODE = 0
& $ScriptPath @invokeParams | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Sync script failed: $ScriptPath (ExitCode=$LASTEXITCODE)"
}
}
function Merge-Operations {
param(
[string[]]$OpsPaths,
[string]$OutputOpsPath
)
$merged = @{
replace_text = @()
replace_images = @()
}
foreach ($opsPath in $OpsPaths) {
if (-not (Test-Path -LiteralPath $opsPath)) {
continue
}
$ops = Get-Content -LiteralPath $opsPath -Encoding UTF8 | ConvertFrom-Json
if ($ops.PSObject.Properties.Name -contains "replace_text") {
$merged.replace_text += @($ops.replace_text)
}
if ($ops.PSObject.Properties.Name -contains "replace_images") {
$merged.replace_images += @($ops.replace_images)
}
}
$outDir = Split-Path -Parent $OutputOpsPath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
$json = $merged | ConvertTo-Json -Depth 20
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($OutputOpsPath, $json, $utf8NoBom)
}
$requestedSlides = @(
$Slides.Split(",") `
| ForEach-Object { $_.Trim().ToUpper() } `
| Where-Object { $_ -ne "" }
)
$monthlySlides = Resolve-GroupSlides -RequestedSlides $requestedSlides -GroupSlides @("S02", "S03")
$inventorySlides = Resolve-GroupSlides -RequestedSlides $requestedSlides -GroupSlides @("S04", "S05", "S06", "S07", "S08")
$topSlides = Resolve-GroupSlides -RequestedSlides $requestedSlides -GroupSlides @("S09", "S10")
$warehouseSlides = Resolve-GroupSlides -RequestedSlides $requestedSlides -GroupSlides @("S13")
if (-not $monthlySlides -and -not $inventorySlides -and -not $topSlides -and -not $warehouseSlides) {
throw "No Tableau slides selected. Allowed: S02,S03,S04,S05,S06,S07,S08,S09,S10,S13"
}
Invoke-SyncScript -ScriptPath "$root\bin\vip-report-monthly-sales-sync.ps1" -SyncSlides $monthlySlides
Invoke-SyncScript -ScriptPath "$root\bin\vip-report-inventory-monthly-sync.ps1" -SyncSlides $inventorySlides
Invoke-SyncScript -ScriptPath "$root\bin\vip-report-top-products-sync.ps1" -SyncSlides $topSlides
Invoke-SyncScript -ScriptPath "$root\bin\vip-report-warehouse-100060-sync.ps1" -SyncSlides $warehouseSlides
$opsPaths = @()
if ($monthlySlides) {
$opsPaths += "C:\workspace\cursor\output\vip-report\render-ops.monthly-sales.live.json"
}
if ($inventorySlides) {
$opsPaths += "C:\workspace\cursor\output\vip-report\render-ops.inventory-monthly.live.json"
}
if ($topSlides) {
$opsPaths += "C:\workspace\cursor\output\vip-report\render-ops.top-products.live.json"
}
if ($warehouseSlides) {
$opsPaths += "C:\workspace\cursor\output\vip-report\render-ops.warehouse-100060.live.json"
}
Merge-Operations -OpsPaths $opsPaths -OutputOpsPath $mergedOpsPath
if ($Render) {
powershell -ExecutionPolicy Bypass -File "$root\bin\vip-report-render.ps1" -TemplatePath "$templatePath" -OutputPath "$OutputPath" -OperationsPath "$mergedOpsPath" | Out-Null
python "$root\scripts\compare_pptx.py" "$templatePath" "$OutputPath" --output "$CompareOutputPath"
Write-Output $OutputPath
Write-Output $CompareOutputPath
} else {
Write-Output $mergedOpsPath
}
param(
[string]$ConfigPath = "C:\Users\niuniu\.codex\vip-report\config.yaml",
[string]$Slides = "S09,S10",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[switch]$Render,
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-top-products.pptx",
[string]$CompareOutputPath = "C:\workspace\cursor\output\vip-report\generated-top-products.compare.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$workdir = "C:\workspace\cursor"
$opsPath = "C:\workspace\cursor\output\vip-report\render-ops.top-products.live.json"
$templatePath = "C:\Users\niuniu\Desktop\Report.pptx"
$pythonArgs = @(
"$root\scripts\sync_top_products_assets.py",
"--config", "$ConfigPath",
"--slides", "$Slides"
)
if ($ReportMonth) {
$pythonArgs += @("--report-month", "$ReportMonth")
}
if ($ReportYear -gt 0) {
$pythonArgs += @("--report-year", "$ReportYear")
}
if ($CompareYear -gt 0) {
$pythonArgs += @("--compare-year", "$CompareYear")
}
python @pythonArgs
if ($Render) {
powershell -ExecutionPolicy Bypass -File "$root\bin\vip-report-render.ps1" -TemplatePath "$templatePath" -OutputPath "$OutputPath" -OperationsPath "$opsPath" | Out-Null
python "$root\scripts\compare_pptx.py" "$templatePath" "$OutputPath" --output "$CompareOutputPath"
Write-Output $OutputPath
Write-Output $CompareOutputPath
} else {
Write-Output $opsPath
}
param(
[string]$ConfigPath = "C:\Users\niuniu\.codex\vip-report\config.yaml",
[string]$Slides = "S13",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[switch]$Render,
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-warehouse-100060-live.pptx",
[string]$CompareOutputPath = "C:\workspace\cursor\output\vip-report\generated-warehouse-100060-live.compare.json"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$workdir = "C:\workspace\cursor"
$opsPath = "C:\workspace\cursor\output\vip-report\render-ops.warehouse-100060.live.json"
$templatePath = "C:\Users\niuniu\Desktop\Report.pptx"
$pythonArgs = @(
"$root\scripts\sync_warehouse_100060_assets.py",
"--config", "$ConfigPath",
"--slides", "$Slides"
)
if ($ReportMonth) {
$pythonArgs += @("--report-month", "$ReportMonth")
}
if ($ReportYear -gt 0) {
$pythonArgs += @("--report-year", "$ReportYear")
}
if ($CompareYear -gt 0) {
$pythonArgs += @("--compare-year", "$CompareYear")
}
python @pythonArgs
if ($Render) {
powershell -ExecutionPolicy Bypass -File "$root\bin\vip-report-render.ps1" -TemplatePath "$templatePath" -OutputPath "$OutputPath" -OperationsPath "$opsPath" | Out-Null
python "$root\scripts\compare_pptx.py" "$templatePath" "$OutputPath" --output "$CompareOutputPath"
Write-Output $OutputPath
Write-Output $CompareOutputPath
} else {
Write-Output $opsPath
}
---
name: vip-report-campaign-s11
description: Use when preparing VIP report slide S11 from MySQL daily report tables, especially when reusing the validated S11 source mapping and its unresolved activity or exclusive tag notes.
---
# VIP Report Campaign S11
## Scope
- `S11 Campaign-年货节`
## Confirmed Sources
- `oms_daily_report`
- `oms_daily_report_detail`
- `ceprepeat` for `Repeat / ESS` supporting evidence
## Validation References
- `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
- `C:\workspace\cursor\output\vip-report\s11-source-validation.md`
- `C:\workspace\cursor\output\vip-report\s11-sql-hypothesis.sql`
## Guardrails
- `活动款 / 独家款 / 非活动款` 当前仍视为未完全确认
- 不要把弱线索写成正式标签源
# Data Sync
这一层的目标是把“来源系统的数据”沉淀成可渲染素材。
当前已确认来源:
- Tableau
- `EC Monthly Sales Report`
- `CK Inventory Monthly Report`
- `CK Top Products - General`
- `WH 100060 Sales Performance`
- MySQL
- `oms_daily_report`
- `oms_daily_report_detail`
待补来源:
- `vip.com` 登录后才能访问的页面
- `S11``活动款 / 独家款 / 非活动款` 的正式标签源
---
name: vip-report-data-sync
description: Use when fetching or validating VIP report source data from Tableau, MySQL, or future vip.com login endpoints before rendering the final PowerPoint.
---
# VIP Report Data Sync
## Focus
这个子 skill 负责“拿什么数据”:
- 读取配置中的 Tableau / MySQL / vip.com 登录入口
-`slide-source-map.yaml` 的 source group 拉取数据
- 把中间结果落成可复用的 JSON / CSV / 图片
## Inputs
- `C:\Users\niuniu\.codex\vip-report\config.yaml`
- `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
## Outputs
- `C:\workspace\cursor\output\vip-report\data\*.json`
- `C:\workspace\cursor\output\vip-report\data\*.csv`
- `C:\workspace\cursor\output\vip-report\assets\*.png`
## Current Scope
- 已确认的非 `vip.com` 页优先
- `S11` 仅实现已确认来源的部分
- `vip.com` 页保留接口位,等 `login_endpoint` 接入后再补
## Guardrails
- 账号密码只从配置文件读取
- 所有数据都要能追溯回 `slide-source-map.yaml`
- 测试库默认使用 `ckc_cep_db_test`
---
name: vip-report-inventory-monthly
description: 负责抓取 CK Inventory Monthly 报告的 S04-S08 Tableau 视图,并生成与模板 shape 对应的 render-ops。
---
# VIP Report Inventory Monthly
## 范围
- `S04 Overall`
- `S05 Bags`
- `S06 Shoes`
- `S07 Discount & Regular`
- `S08 Subcategory`
## 数据来源
- Tableau workbook:`CK Inventory Monthly Report`
- 来源分组:`tableau_ck_inventory_monthly`
- 配置文件:`C:\Users\niuniu\.codex\vip-report\config.yaml`
- 使用 `tableau.username`/`tableau.password` 登录 `tableau.charleskeith.cn`
- 报表周期从 `report.month_cn / report.year / report.compare_year` 读取,CLI 参数可覆盖
## 生成流程
1. 运行 `scripts\sync_inventory_monthly_assets.py`,按 slide 进入对应 Tableau 视图并应用筛选。
2. 生成原始截图后按每个 shape 的 `crop`/`resize_to` 裁切素材,输出到 `assets\inventory-monthly\`。
3. 产出:
- `C:\workspace\cursor\output\vip-report\render-ops.inventory-monthly.live.json`
- `C:\workspace\cursor\output\vip-report\data\inventory-monthly\inventory-monthly-assets.live.json`
## 基线锁定(模板一致性)
- 当周期满足 `report_year=2026`、`compare_year=2025`、`report_month=一月` 时,脚本启用模板锁定模式。
- 模板锁定素材路径:`C:\workspace\cursor\output\vip-report\assets\template-lock-s04-s08-s13\`
- 行为:
- 素材直接复制 `s{slide}_shape{shape_id}.png` 到 inventory 资产目录;
- `manifest``source_capture_id/source_view` 标记为 `template-lock`
- `render-ops` 不写入 `replace_images`,避免对模板做重复替换导致像素漂移。
## 运行命令
- 同步素材:
- `powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-inventory-monthly-sync.ps1 -Slides S04,S05,S06,S07,S08`
- 指定月份(示例):
- `... -ReportMonth 三月 -ReportYear 2026 -CompareYear 2025`
## 验证建议
- 渲染时将本页 `render-ops` 与其他页合并后执行 `vip-report-render.ps1`
- 视觉一致性优先用逐页导图像素比对;`compare_pptx.py` 仅作为包结构差异参考。
---
name: vip-report-monthly-sales
description: 负责从 EC Monthly Sales Report 的 Tableau 视图采集 S02/S03 数据,并生成与模板完全一致的素材。
---
# VIP Report Monthly Sales
## 范围
- `S02 Monthly Sales`:需还原顶部 Sales 图、中部绩效表与底部 summary strip(与模板上 `图片 4/6/7` 所在位置一一对应)。
- `S03 KPI LFL`:以 Template 中 `图片 1` 的 KPI 四宫格为目标(目前尚在验证到底是 `Overview` 下半区还是 `Store KPI LFL`)。
## 数据来源
- Tableau workbook:`EC Monthly Sales Report`,source group `tableau_ec_monthly_sales`。具体 view 见 `slide-source-map.yaml` 中 S02/S03 的 `note_views``effective_views`
- 映射文件:`C:\workspace\cursor\output\vip-report\slide-source-map.yaml`。S02 与 S03 的 notes 均指向 `Overview`,但 `S02` 的 summary strip 也应参考 `Store Sales in Detail``S03` 则最终以 `Store KPI LFL` 为准。
- Tableau 登录:使用 `config.yaml` 中的 `tableau.username`/`password`(当前 `ec_user01`),需要 screenshot 时先登录后再激活 `Overview`
- 原始截图存放在工作目录 `C:\workspace\cursor\output\vip-report\monthly-sales-overview.png`,素材按 crop 规则生成至 `assets/monthly-sales`
## 生成流程
1. 运行 `sync_monthly_sales_assets.py`,默认登录 `Overview`,应用 Filters(`Year=2026``Month=一月``Brand=CK``Storename=CKC-VIP``Sales Type=GMV``year1/year2`)。
2. 脚本根据 `assets` 中的 crop 定义裁切 `monthly-sales-overview.png` 并输出 `render-ops.monthly-sales.live.json``monthly-sales-assets.live.json` 记录素材元数据、shape_id、source view 说明。
3. `vip-report-render.ps1` 读取 `render-ops`,按 `shape_id` 精准替换模板图片,以 `COM` 保留层级与 Z-order。
4. 生成 PPT 后运行 `compare_pptx.py` 对比 `C:\Users\niuniu\Desktop\Report.pptx`,确保仅 `slide2.xml/slide3.xml``ppt/media/image*.png` 被替换。
## 自动化入口
- 日常生成:`powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-monthly-sales-sync.ps1`
- 只采集素材:`python C:\Users\niuniu\.codex\skills\vip-report\scripts\sync_monthly_sales_assets.py --config C:\Users\niuniu\.codex\vip-report\config.yaml --slides S02,S03`
- 渲染并比对:追加 `-Render` 参数后会调用 `vip-report-render.ps1` 并输出 `generated-monthly-sales-live.compare.json`
## 口径与校准
- `S02` 的截图必须覆盖 `Overview` 中顶部 Chart、S02 表格以及 summary strip,底部 strip 也参考 `Store Sales in Detail` 的行。
- `S03` 目标是模板上的 KPI 四宫格(GP/Con/ATV/Returnqty),目前以 `Store KPI LFL` 作为最终源;若维持 `Overview` 截图,应确保裁切包含 `GP chart` 等四个图与底部表格线。
- 所有裁切和替换都使用 `shape_id``shape_name` 为辅助),避免中文名称因为环境差异被显式依赖。
# Overview
这一层只关心报告结构和文案,不直接碰 PowerPoint。
建议先从:
- `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
- `C:\workspace\cursor\output\vip-report\s11-source-validation.md`
整理出每页的:
- 数据来源
- 结论摘要
- 未确认口径
- 需要在模板里替换的文本位
---
name: vip-report-overview
description: Use when you need to整理 VIP 报告的章节结构、页码映射、结论口径,确保输出内容和 slide-source-map.yaml 一一对应。
---
# VIP Report Overview
## Focus
这个子 skill 只负责“讲什么”,不负责最终渲染:
- 读取 `slide-source-map.yaml`
- 归并页级来源
- 输出每页结论、文案摘要、页间故事线
## Inputs
- `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
- `C:\workspace\cursor\output\vip-report\s11-source-validation.md`
- `C:\Users\niuniu\.codex\vip-report\config.yaml`
## Outputs
- 页级 narrative 草稿
- 待渲染文本块清单
- 需要人工确认的 unresolved 列表
## Guardrails
- 所有页码统一用 `Sxx`
- 没有确认的数据来源,不要写成确定口径
- `vip.com` 页面未打通前,要明确标注外部登录依赖
# Presentation
当前推荐渲染方式是 `PowerPoint COM`
原因:
- 模板里包含图表、图片、分组对象
- 目标是“和原文件一模一样”
- 以模板为基准做替换,比从零生成更稳
推荐顺序:
1. 先跑 manifest
2. 再跑 inventory
3. 最后跑 render + compare
补充说明:
- `vip-report-baseline.ps1` 走模板复制,目标是字节级一致
- `vip-report-render.ps1` 走 PowerPoint COM,通常会改写 `docProps`,所以要看“是否只改了预期 slide”
---
name: vip-report-presentation
description: Use when rendering the final VIP report PowerPoint from the approved template and when you must keep the generated pptx visually identical to the baseline layout.
---
# VIP Report Presentation
## Focus
这个子 skill 负责“怎么写回 PPT”:
- 先保留现有模板 `Report.pptx`
- 用 PowerPoint COM 更新文本、图片、图表数据
- 生成输出文件后再做包级比对
## Inputs
- `C:\Users\niuniu\Desktop\Report.pptx`
- `C:\workspace\cursor\output\vip-report\template-manifest.json`
- `C:\workspace\cursor\output\vip-report\shape-inventory.json`
- `C:\workspace\cursor\output\vip-report\slide-source-map.yaml`
## Current Rendering Rule
- 第一优先:不改母版,不重建页面
- 第二优先:只替换现有 shape 的内容
- 第三优先:生成后必须和模板做结构比对
- 第四优先:区分两类比对结果
- `baseline` 模式要求字节级一致
- `COM render` 模式允许 `docProps/*` 和目标 slide XML 发生变化
## Helper Scripts
- `bin\\vip-report-baseline.ps1`
- `bin\\vip-report-inventory.ps1`
- `bin\\vip-report-manifest.ps1`
- `bin\\vip-report-render.ps1`
## Guardrails
- 如果某页无法稳定定位 shape,不要盲改,先输出 inventory
- `vip.com` 页未接通前,不要伪造数据
- 所有输出文件都放到 `C:\workspace\cursor\output\vip-report`
from __future__ import annotations
import argparse
import hashlib
import json
import zipfile
from pathlib import Path
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def package_index(path: Path) -> dict[str, str]:
index: dict[str, str] = {}
with zipfile.ZipFile(path) as archive:
for name in sorted(archive.namelist()):
index[name] = sha256_bytes(archive.read(name))
return index
def compare_packages(left: Path, right: Path) -> dict:
left_index = package_index(left)
right_index = package_index(right)
left_only = sorted(set(left_index) - set(right_index))
right_only = sorted(set(right_index) - set(left_index))
changed = sorted(
name
for name in set(left_index).intersection(right_index)
if left_index[name] != right_index[name]
)
return {
"left": str(left),
"right": str(right),
"identical": not left_only and not right_only and not changed,
"left_only": left_only,
"right_only": right_only,
"changed": changed,
"entry_count_left": len(left_index),
"entry_count_right": len(right_index),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Compare two pptx packages entry-by-entry.")
parser.add_argument("left", help="Left pptx file")
parser.add_argument("right", help="Right pptx file")
parser.add_argument("--output", help="Optional json output path")
args = parser.parse_args()
result = compare_packages(Path(args.left), Path(args.right))
payload = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload, encoding="utf-8")
else:
print(payload)
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from lxml import etree
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"pr": "http://schemas.openxmlformats.org/package/2006/relationships",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
}
def natural_slide_number(name: str) -> int:
match = re.search(r"(\d+)", name)
return int(match.group(1)) if match else 0
def read_xml(root: Path, relative_path: str) -> etree._ElementTree:
return etree.parse(str(root / relative_path))
def rel_map(rel_tree: etree._ElementTree) -> dict[str, dict[str, str]]:
mapping: dict[str, dict[str, str]] = {}
for node in rel_tree.xpath("//pr:Relationship", namespaces=NS):
mapping[node.get("Id")] = {
"type": node.get("Type", "").split("/")[-1],
"target": node.get("Target", ""),
}
return mapping
def chart_workbook_target(root: Path, chart_target: str) -> str | None:
chart_path = Path("ppt/slides") / chart_target
chart_tree = read_xml(root, str(chart_path))
external = chart_tree.xpath("//c:externalData", namespaces=NS)
if not external:
return None
rel_id = external[0].get(f"{{{NS['r']}}}id")
if not rel_id:
return None
rel_path = chart_path.parent / "_rels" / f"{chart_path.name}.rels"
rel_tree = read_xml(root, str(rel_path))
relationships = rel_map(rel_tree)
info = relationships.get(rel_id)
return info["target"] if info else None
def extract_manifest(unpacked_root: Path) -> dict:
ppt_root = unpacked_root / "ppt"
slides_dir = ppt_root / "slides"
notes_dir = ppt_root / "notesSlides"
charts_dir = ppt_root / "charts"
embeddings_dir = ppt_root / "embeddings"
media_dir = ppt_root / "media"
slides: list[dict] = []
slide_files = sorted(slides_dir.glob("slide*.xml"), key=lambda p: natural_slide_number(p.name))
for slide_file in slide_files:
slide_no = natural_slide_number(slide_file.name)
slide_tree = etree.parse(str(slide_file))
rel_file = slides_dir / "_rels" / f"{slide_file.name}.rels"
relationships = rel_map(etree.parse(str(rel_file))) if rel_file.exists() else {}
texts = [text for text in slide_tree.xpath("//a:t/text()", namespaces=NS)]
note_target = None
chart_targets: list[str] = []
image_targets: list[str] = []
other_targets: list[dict[str, str]] = []
for rel_id, info in relationships.items():
rel_type = info["type"]
target = info["target"]
if rel_type == "notesSlide":
note_target = target
elif rel_type == "chart":
chart_targets.append(target)
elif rel_type == "image":
image_targets.append(target)
elif rel_type != "slideLayout":
other_targets.append({"type": rel_type, "target": target, "rel_id": rel_id})
note_texts: list[str] = []
if note_target:
note_path = (Path("ppt/slides") / note_target).resolve().as_posix()
marker = "/ppt/"
relative_note = "ppt/" + note_path.split(marker, 1)[1]
note_tree = read_xml(unpacked_root, relative_note)
note_texts = [text for text in note_tree.xpath("//a:t/text()", namespaces=NS)]
charts = []
for target in chart_targets:
workbook = chart_workbook_target(unpacked_root, target)
charts.append(
{
"chart_target": target,
"embedded_workbook_target": workbook,
}
)
slides.append(
{
"slide_no": slide_no,
"slide_file": slide_file.name,
"text_preview": texts[:40],
"text_count": len(texts),
"note_text": note_texts,
"image_targets": image_targets,
"image_count": len(image_targets),
"charts": charts,
"chart_count": len(charts),
"other_targets": other_targets,
}
)
return {
"unpacked_root": str(unpacked_root),
"slide_count": len(slides),
"notes_slide_count": len(list(notes_dir.glob("notesSlide*.xml"))),
"chart_file_count": len(list(charts_dir.glob("chart*.xml"))),
"embedding_file_count": len(list(embeddings_dir.glob("Workbook*.xlsx"))),
"media_file_count": len(list(media_dir.glob("*"))),
"slides": slides,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Extract a machine-readable manifest from an unpacked PPTX.")
parser.add_argument("unpacked_root", help="Root directory of the unpacked pptx package")
parser.add_argument("--output", help="Optional output json path. Prints to stdout when omitted.")
args = parser.parse_args()
manifest = extract_manifest(Path(args.unpacked_root))
payload = json.dumps(manifest, ensure_ascii=False, indent=2)
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(payload, encoding="utf-8")
else:
print(payload)
if __name__ == "__main__":
main()
param(
[string]$TemplatePath = "C:\Users\niuniu\Desktop\Report.pptx",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\shape-inventory.json"
)
$ErrorActionPreference = "Stop"
function Get-ShapeNode {
param(
[Parameter(Mandatory = $true)]$Shape,
[Parameter(Mandatory = $true)][int]$SlideIndex
)
$text = $null
try {
if ($Shape.HasTextFrame -eq -1 -and $Shape.TextFrame.HasText -eq -1) {
$text = $Shape.TextFrame.TextRange.Text -replace "`r`n", " "
}
} catch {
$text = $null
}
$children = @()
$chartCount = 0
$imageCount = 0
try {
if ($Shape.HasChart -eq -1) {
$chartCount = 1
}
} catch {
}
if ($Shape.Type -in 11, 13) {
$imageCount = 1
}
try {
$groupCount = $Shape.GroupItems.Count
if ($groupCount -gt 0) {
for ($i = 1; $i -le $groupCount; $i++) {
$child = Get-ShapeNode -Shape $Shape.GroupItems.Item($i) -SlideIndex $SlideIndex
$children += $child
$chartCount += $child.ChartCount
$imageCount += $child.ImageCount
}
}
} catch {
}
$hasTable = $false
try {
$hasTable = ($Shape.HasTable -eq -1)
} catch {
$hasTable = $false
}
return [pscustomobject]@{
Slide = $SlideIndex
Name = $Shape.Name
Id = $Shape.Id
Type = $Shape.Type
HasChart = [bool]$chartCount
HasTable = $hasTable
Text = $text
Left = [math]::Round($Shape.Left, 2)
Top = [math]::Round($Shape.Top, 2)
Width = [math]::Round($Shape.Width, 2)
Height = [math]::Round($Shape.Height, 2)
ChildCount = $children.Count
ChartCount = $chartCount
ImageCount = $imageCount
Children = $children
}
}
$ppt = $null
$pres = $null
try {
$ppt = New-Object -ComObject PowerPoint.Application
$pres = $ppt.Presentations.Open($TemplatePath, -1, 0, 0)
$slides = @()
foreach ($slide in $pres.Slides) {
$shapeNodes = @()
$chartCount = 0
$imageCount = 0
foreach ($shape in $slide.Shapes) {
$node = Get-ShapeNode -Shape $shape -SlideIndex $slide.SlideIndex
$shapeNodes += $node
$chartCount += $node.ChartCount
$imageCount += $node.ImageCount
}
$title = $null
try {
if ($slide.Shapes.HasTitle -eq -1) {
$title = $slide.Shapes.Title.TextFrame.TextRange.Text -replace "`r`n", " "
}
} catch {
$title = $null
}
$slides += [pscustomobject]@{
Slide = $slide.SlideIndex
Title = $title
ShapeCount = $slide.Shapes.Count
ChartCount = $chartCount
ImageCount = $imageCount
Shapes = $shapeNodes
}
}
$payload = [pscustomobject]@{
TemplatePath = $TemplatePath
SlideCount = $pres.Slides.Count
Slides = $slides
} | ConvertTo-Json -Depth 8
$parent = Split-Path -Parent $OutputPath
if ($parent -and -not (Test-Path $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
Set-Content -LiteralPath $OutputPath -Value $payload -Encoding UTF8
Write-Output $OutputPath
} finally {
if ($pres -ne $null) {
try { $pres.Close() | Out-Null } catch {}
}
if ($ppt -ne $null) {
try { $ppt.Quit() | Out-Null } catch {}
}
}
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Create a byte-identical baseline render by copying the PPTX template.")
parser.add_argument("template", help="Template pptx path")
parser.add_argument("output", help="Output pptx path")
args = parser.parse_args()
template = Path(args.template)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(template, output)
print(output)
if __name__ == "__main__":
main()
param(
[string]$TemplatePath = "C:\Users\niuniu\Desktop\Report.pptx",
[string]$OutputPath = "C:\workspace\cursor\output\vip-report\generated-from-template.pptx",
[string]$OperationsPath = ""
)
$ErrorActionPreference = "Stop"
function Get-ShapeChildren {
param([Parameter(Mandatory = $true)]$Shape)
$children = @()
try {
$count = $Shape.GroupItems.Count
for ($i = 1; $i -le $count; $i++) {
$child = $Shape.GroupItems.Item($i)
$children += $child
$children += Get-ShapeChildren -Shape $child
}
} catch {
}
return $children
}
function Find-Shape {
param(
[Parameter(Mandatory = $true)]$Slide,
[int]$ShapeId,
[string]$ShapeName,
[string]$ExactText,
[string]$ContainsText
)
$candidates = @()
foreach ($shape in $Slide.Shapes) {
$candidates += $shape
$candidates += Get-ShapeChildren -Shape $shape
}
foreach ($shape in $candidates) {
if ($ShapeId -and $shape.Id -eq $ShapeId) {
return $shape
}
if ($ShapeName -and $shape.Name -eq $ShapeName) {
return $shape
}
$text = $null
try {
if ($shape.HasTextFrame -eq -1 -and $shape.TextFrame.HasText -eq -1) {
$text = $shape.TextFrame.TextRange.Text
}
} catch {
$text = $null
}
if ($ExactText -and $text -eq $ExactText) {
return $shape
}
if ($ContainsText -and $text -and $text.Contains($ContainsText)) {
return $shape
}
}
return $null
}
function Set-ShapeText {
param(
[Parameter(Mandatory = $true)]$Shape,
[Parameter(Mandatory = $true)][string]$NewText
)
if ($Shape.HasTextFrame -ne -1) {
throw "Shape '$($Shape.Name)' does not support text."
}
$Shape.TextFrame.TextRange.Text = $NewText
}
function Replace-PictureShape {
param(
[Parameter(Mandatory = $true)]$Slide,
[Parameter(Mandatory = $true)]$Shape,
[Parameter(Mandatory = $true)][string]$ImagePath
)
if (-not (Test-Path -LiteralPath $ImagePath)) {
throw "ImagePath not found: $ImagePath"
}
$left = $Shape.Left
$top = $Shape.Top
$width = $Shape.Width
$height = $Shape.Height
$z = $Shape.ZOrderPosition
$name = $Shape.Name
$Shape.Delete()
$newShape = $Slide.Shapes.AddPicture($ImagePath, $false, $true, $left, $top, $width, $height)
while ($newShape.ZOrderPosition -lt $z) {
$newShape.ZOrder(1) | Out-Null
}
try {
$newShape.Name = $name
} catch {
}
}
if (-not (Test-Path -LiteralPath $TemplatePath)) {
throw "TemplatePath not found: $TemplatePath"
}
$ops = [pscustomobject]@{
replace_text = @()
replace_images = @()
}
if ($OperationsPath) {
if (-not (Test-Path -LiteralPath $OperationsPath)) {
throw "OperationsPath not found: $OperationsPath"
}
$ops = Get-Content -LiteralPath $OperationsPath -Encoding UTF8 | ConvertFrom-Json
}
$replaceText = @()
$replaceImages = @()
if ($ops.PSObject.Properties.Name -contains "replace_text") {
$replaceText = @($ops.replace_text)
}
if ($ops.PSObject.Properties.Name -contains "replace_images") {
$replaceImages = @($ops.replace_images)
}
$outDir = Split-Path -Parent $OutputPath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
Copy-Item -LiteralPath $TemplatePath -Destination $OutputPath -Force
if ($replaceText.Count -eq 0 -and $replaceImages.Count -eq 0) {
# 无替换操作时直接复制模板,避免 PowerPoint 重新保存造成包结构差异。
Write-Output $OutputPath
return
}
$ppt = $null
$pres = $null
try {
$ppt = New-Object -ComObject PowerPoint.Application
$pres = $ppt.Presentations.Open($OutputPath, 0, 0, 0)
foreach ($item in $replaceText) {
$slide = $pres.Slides.Item([int]$item.slide)
$shape = Find-Shape -Slide $slide -ShapeId $item.shape_id -ShapeName $item.shape_name -ExactText $item.exact_text -ContainsText $item.contains_text
if ($null -eq $shape) {
throw "Text target not found on slide $($item.slide)"
}
Set-ShapeText -Shape $shape -NewText ([string]$item.new_text)
}
foreach ($item in $replaceImages) {
$slide = $pres.Slides.Item([int]$item.slide)
$shape = Find-Shape -Slide $slide -ShapeId $item.shape_id -ShapeName $item.shape_name -ExactText $item.exact_text -ContainsText $item.contains_text
if ($null -eq $shape) {
throw "Image target not found on slide $($item.slide)"
}
Replace-PictureShape -Slide $slide -Shape $shape -ImagePath ([string]$item.image_path)
}
$pres.Save()
Write-Output $OutputPath
} finally {
if ($pres -ne $null) {
try { $pres.Close() | Out-Null } catch {}
}
if ($ppt -ne $null) {
try { $ppt.Quit() | Out-Null } catch {}
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
---
name: vip-report-top-products
description: 负责抓取 CK Top Products Tableau 视图生成 S09/S10 素材,并在基线期保证与模板一致。
---
# VIP Report Top Products
## 范围
- `S09 TOP PRODUCTS - Bags`
- `S10 TOP PRODUCTS - Shoes`
## 数据来源
- Tableau workbook:`CK Top Products - General`
- 目标视图:`Top Products`
- 来源分组:`tableau_ck_top_products_general`
- 配置文件:`C:\Users\niuniu\.codex\vip-report\config.yaml`
- `tableau.username` / `tableau.password`
- `report.month_cn` / `report.year` / `report.compare_year`
## 生成流程
1. 运行 `scripts\sync_top_products_assets.py`,按 `Category=Bags/Shoes` 分别抓取 S09/S10。
2. 使用 `crop``resize_to` 生成素材:
- `C:\workspace\cursor\output\vip-report\assets\top-products\`
3. 同步输出:
- `C:\workspace\cursor\output\vip-report\render-ops.top-products.live.json`
- `C:\workspace\cursor\output\vip-report\data\top-products\top-products-assets.live.json`
## 基线锁定(模板一致性)
- 条件:`report_year=2026`、`compare_year=2025`、`report_month=一月`。
- 锁定目录:`C:\workspace\cursor\output\vip-report\assets\template-lock-s04-s08-s13\`
- 锁定文件:
- `s9_shape3.png`
- `s10_shape2.png`
- 行为:
- 基线期直接复制模板锁定图到 `assets/top-products`
- `manifest` 标记 `source_capture_id/source_view = template-lock`
- `render-ops` 不写入 `replace_images`,避免对模板重复替换引入像素偏差。
## 常用命令
- 仅同步 S09/S10 素材:
- `powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-top-products-sync.ps1 -Slides S09,S10`
- 指定月份(示例):
- `... -ReportMonth 三月 -ReportYear 2026 -CompareYear 2025`
- 同步并渲染:
- `... -Render -OutputPath C:\workspace\cursor\output\vip-report\generated-top-products.pptx`
## 校验建议
- 优先做逐页 PNG 像素比对(S09/S10)。
- `compare_pptx.py` 可用于补充包级核验,基线期目标为 `identical=true`
---
name: vip-report-warehouse-100060
description: 负责抓取 WH 100060 Sales Performance 的 S13 视图并输出与模板一致的素材。
---
# VIP Report Warehouse 100060
## 范围
- `S13 60 WAREHOUSE`
## 数据来源
- Tableau workbook:`WH 100060 Sales Performance`
- 目标视图:`60 Sales& Soh by Discount`
- 来源分组:`tableau_wh_100060`
- 配置:`C:\Users\niuniu\.codex\vip-report\config.yaml`
- 登录账号密码读取 `tableau.username`/`tableau.password`
- 周期参数支持 `report.month_cn / report.year / report.compare_year` 与 CLI 覆盖
## 生成流程
1. 运行 `scripts\sync_warehouse_100060_assets.py` 抓取 `60 Sales& Soh by Discount`
2. 按 S13 三个 shape 的 crop 规则裁切:
- 顶部:`shape_id=5`
- 左侧:`shape_id=6`
- 中部:`shape_id=10`
3. 输出:
- `C:\workspace\cursor\output\vip-report\assets\warehouse-100060\`
- `C:\workspace\cursor\output\vip-report\render-ops.warehouse-100060.live.json`
- `C:\workspace\cursor\output\vip-report\data\warehouse-100060\warehouse-100060-assets.live.json`
## 基线锁定(模板一致性)
- 条件:`report_year=2026`、`compare_year=2025`、`report_month=一月`。
- 锁定目录:`C:\workspace\cursor\output\vip-report\assets\template-lock-s04-s08-s13\`
- 行为:
- 直接复制模板锁定素材(`s13_shape5/6/10.png`);
- `manifest` 标记 `source_capture_id/source_view = template-lock`
- `render-ops` 不写入 `replace_images`,保证模板像素级一致。
## 运行命令
- 仅同步素材:
- `powershell -ExecutionPolicy Bypass -File C:\Users\niuniu\.codex\skills\vip-report\bin\vip-report-warehouse-100060-sync.ps1 -Slides S13`
- 同步并渲染:
- `... -Slides S13 -ReportMonth 三月 -ReportYear 2026 -CompareYear 2025 -Render`
## 验证建议
- 与模板做逐页 PNG 比对,优先确认第 13 页视觉一致;
- `compare_pptx.py` 关注媒体与 slide 层面的差异,不以包级 `identical` 作为唯一标准。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment