Commit 52222f72 authored by 常佳涛's avatar 常佳涛

Update VIP report automation and PPT

parent 4b9160b1
......@@ -40,3 +40,11 @@ vip-report/.mpl-cache/
vip-report/.npm-cache/
vip-report/shadowbot_flow*_bundle/
vip-report/shadowbot_flow*_bundle.zip
# VIP report local backups and inspection workdirs
sql_exports_local/
vip-report/tmp*/
vip-report/Report.*backup*.pptx
vip-report/Report.repaired.pptx
vip-report/Report - *.pptx
vip-report/config.yaml.bak*
No preview for this file type
......@@ -31,4 +31,67 @@ $TemplatePath = Resolve-ProjectPath $TemplatePath
$OutputPath = Resolve-ProjectPath $OutputPath
$OperationsPath = Resolve-ProjectPath $OperationsPath
powershell -ExecutionPolicy Bypass -File "$root\scripts\render_template_com.ps1" -TemplatePath "$TemplatePath" -OutputPath "$OutputPath" -OperationsPath "$OperationsPath"
function Resolve-PythonCommand {
$venvPython = Join-Path $root ".venv\Scripts\python.exe"
if (Test-Path -LiteralPath $venvPython) {
return ,$venvPython
}
if (Get-Command py -ErrorAction SilentlyContinue) {
return @("py", "-3")
}
if (Get-Command python -ErrorAction SilentlyContinue) {
return @("python")
}
return @()
}
function Has-NativeChartOps {
param([string]$OpsPath)
if (-not $OpsPath -or -not (Test-Path -LiteralPath $OpsPath)) {
return $false
}
try {
$ops = Get-Content -LiteralPath $OpsPath -Encoding UTF8 -Raw | ConvertFrom-Json
if ($ops.PSObject.Properties.Name -contains "replace_native_charts") {
return (@($ops.replace_native_charts).Count -gt 0)
}
}
catch {
}
return $false
}
try {
& "$root\scripts\render_template_com.ps1" -TemplatePath "$TemplatePath" -OutputPath "$OutputPath" -OperationsPath "$OperationsPath"
}
catch {
$comError = $_
$canFallback = Has-NativeChartOps -OpsPath $OperationsPath
if (-not $canFallback) {
Write-Error $comError
exit 1
}
$pythonCmd = @(Resolve-PythonCommand)
if ($pythonCmd.Count -eq 0) {
Write-Error "COM render failed and python fallback is unavailable (python not found). Original error: $comError"
exit 1
}
$fallbackScript = Join-Path $root "scripts\render_template_openxml.py"
if (-not (Test-Path -LiteralPath $fallbackScript)) {
Write-Error "COM render failed and fallback script is missing: $fallbackScript"
exit 1
}
Write-Warning "COM render failed; falling back to python-pptx renderer."
if ($pythonCmd.Count -eq 2) {
& $pythonCmd[0] $pythonCmd[1] $fallbackScript --template "$TemplatePath" --output "$OutputPath" --operations "$OperationsPath"
} else {
& $pythonCmd[0] $fallbackScript --template "$TemplatePath" --output "$OutputPath" --operations "$OperationsPath"
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Fallback openxml renderer failed (exit=$LASTEXITCODE). Original COM error: $comError"
exit 1
}
}
......@@ -7,7 +7,7 @@ param(
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[ValidateSet("single", "cumulative")]
[string]$MonthlySalesMode = "single",
[string]$MonthlySalesMode = "cumulative",
[int]$Retries = 3,
[int]$RetryDelaySeconds = 8,
[string]$TemplatePath = "",
......@@ -225,6 +225,7 @@ function Merge-Operations {
replace_tables = @()
replace_charts = @()
replace_images = @()
replace_native_charts = @()
}
foreach ($opsPath in $OpsPaths) {
......@@ -244,6 +245,9 @@ function Merge-Operations {
if ($ops.PSObject.Properties.Name -contains "replace_images") {
$merged.replace_images += @($ops.replace_images)
}
if ($ops.PSObject.Properties.Name -contains "replace_native_charts") {
$merged.replace_native_charts += @($ops.replace_native_charts)
}
}
$json = $merged | ConvertTo-Json -Depth 100
......
param(
[string]$TableImage = "D:\projects\skills\vip-report\output\vip-report\assets\cep-traffic\cep-traffic-summary.png",
[string]$LineImage = "D:\projects\skills\vip-report\output\vip-report\assets\cep-site-traffic-entry\cep-site-traffic-entry-lfl.png",
[string]$EntryImage = "D:\projects\skills\vip-report\output\vip-report\assets\cep-traffic-summary\cep-traffic-summary-lfl.png",
[string]$OutputPath = "D:\projects\skills\vip-report\output\vip-report\assets\uv-lfl\uv-lfl-report.png",
[int]$ContentWidth = 1040
[string]$ConfigPath = "D:\projects\skills\vip-report\config.yaml",
[string]$ReportMonth = "",
[int]$ReportYear = 0,
[int]$CompareYear = 0,
[string]$OutputOps = ""
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
python "$root\scripts\compose_uv_lfl_report.py" `
--table-image "$TableImage" `
--line-image "$LineImage" `
--entry-image "$EntryImage" `
--output "$OutputPath" `
--content-width "$ContentWidth"
$pythonArgs = @(
"$root\scripts\build_slide12_editable_ops.py",
"--config", "$ConfigPath"
)
if ($ReportMonth) {
$pythonArgs += @("--report-month", "$ReportMonth")
}
if ($ReportYear -gt 0) {
$pythonArgs += @("--report-year", "$ReportYear")
}
if ($CompareYear -gt 0) {
$pythonArgs += @("--compare-year", "$CompareYear")
}
if ($OutputOps) {
$pythonArgs += @("--output-ops", "$OutputOps")
}
python @pythonArgs
......@@ -4,10 +4,10 @@ tableau:
password: "1!Cu&euRo17b6yu@R40gr7"
mysql:
host: "192.168.138.182"
host: "127.0.0.1"
port: 3306
username: "ceptest"
password: "ck@123456"
username: "root"
password: "root"
database: "ckc_cep_db_test"
paths:
......@@ -19,7 +19,7 @@ render:
mode: "com"
report:
month_cn: "3"
month_cn: "5"
year: 2026
compare_year: 2025
......
......@@ -4,3 +4,4 @@ PyYAML
lxml
matplotlib
numpy
openpyxl
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
import pymysql
import yaml
def dec(value: object) -> Decimal:
return Decimal(str(value or 0))
def pct(value: object) -> str:
return f"{(dec(value) * 100).quantize(Decimal('0.01'))}%"
def wan(value: object) -> str:
return f"{(dec(value) / Decimal('10000')).quantize(Decimal('0.1'))}w"
def sign_pct(curr: object, prev: object) -> str:
curr_d = dec(curr)
prev_d = dec(prev)
if prev_d == 0:
return "0%"
q = ((curr_d - prev_d) / prev_d * 100).quantize(Decimal("1"))
return ("+" if q > 0 else "") + f"{q}%"
def main() -> None:
root = Path(__file__).resolve().parent.parent
cfg = yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8"))
mysql = cfg["mysql"]
conn = pymysql.connect(
host=mysql["host"],
port=int(mysql.get("port", 3306)),
user=mysql.get("username") or mysql.get("user"),
password=mysql["password"],
database=mysql["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
SUM(accumulated_viewers) viewers,
SUM(guided_sales_total) guided_sales,
SUM(brand_new_customers_total) new_customers,
SUM(live_sales_penetration_rate * accumulated_viewers) / NULLIF(SUM(accumulated_viewers), 0) live_pen,
SUM(purchasers_total) / NULLIF(SUM(accumulated_viewers), 0) conversion_rate
FROM cep_live_session_data
WHERE data_month='2026-05'
"""
)
live = cur.fetchone()
cur.execute(
"""
SELECT
SUM(accumulated_viewers) viewers,
SUM(guided_sales_total) guided_sales,
SUM(brand_new_customers_total) new_customers,
SUM(live_sales_penetration_rate * accumulated_viewers) / NULLIF(SUM(accumulated_viewers), 0) live_pen,
SUM(purchasers_total) / NULLIF(SUM(accumulated_viewers), 0) conversion_rate
FROM cep_live_session_data
WHERE data_month='2026-04'
"""
)
live_prev = cur.fetchone()
cur.execute(
"""
SELECT
COUNT(DISTINCT product_spu_id) articles,
SUM(item_count_total) qty,
SUM(sales_total) sales,
SUM(brand_new_customer_count) new_customers
FROM cep_live_goods_effect
WHERE data_month='2026-05'
"""
)
goods = cur.fetchone()
cur.execute(
"""
SELECT
CASE
WHEN category_level_1 IN ('配饰', '珠宝首饰') THEN '配饰'
ELSE category_level_1
END AS cat,
SUM(item_count_total) qty
FROM cep_live_goods_effect
WHERE data_month='2026-05'
GROUP BY cat
"""
)
goods_cats = cur.fetchall()
finally:
conn.close()
cat_total = sum(dec(row["qty"]) for row in goods_cats) or Decimal(1)
cat_parts: list[str] = []
for row in goods_cats:
name = str(row["cat"] or "未知")
share = (dec(row["qty"]) / cat_total * 100).quantize(Decimal("0"))
cat_parts.append(f"{name}{share}%")
texts = {
"cover": "E-Commerce Monthly Sales (VIP Store)May 2026",
"top_bags": "5月流水销量TOP10包款已按五月网页抓取结果更新;榜单、商品图和销售走势来自Tableau五月筛选数据。",
"top_shoes": "5月流水销量TOP10鞋款已按五月网页抓取结果更新;榜单、商品图和销售走势来自Tableau五月筛选数据。",
"uv": "同比去年5月,流量看板已按Compass五月流量来源数据更新;站内/站外、付费/自然流量及入口结构均来自本地库五月抓取结果。",
"warehouse": "本页60仓表现已按Tableau五月筛选结果更新,包含折扣分布、箱包TOP和鞋类TOP三个模块。",
"live14": (
f"本月直播累计观看人数{wan(live['viewers'])},直播渗透率{pct(live['live_pen'])},"
f"环比{sign_pct(live['live_pen'], live_prev['live_pen'])};转化率{pct(live['conversion_rate'])},"
f"总引导销售{wan(live['guided_sales'])},品牌新客数{int(dec(live['new_customers'])):,}。"
"直播图表来自本地库cep_live_session_data五月数据。"
),
"live15": (
f"鞋包直播间有销售商品款数总计{int(dec(goods['articles']))}款,类目结构为{'、'.join(cat_parts)}。"
f"销售额{wan(goods['sales'])},直播商品榜单来自cep_live_goods_effect五月商品效果数据。"
),
"member": "本页会员看板已按五月网页截图更新,四个模块图片来自会员看板五月筛选后的自动截图。",
}
ops = {
"replace_text": [
{"slide": 1, "shape_id": 3, "new_text": texts["cover"]},
{"slide": 9, "shape_id": 32, "new_text": texts["top_bags"]},
{"slide": 10, "shape_id": 15, "new_text": texts["top_shoes"]},
{"slide": 12, "shape_id": 11, "new_text": texts["uv"]},
{"slide": 13, "shape_id": 16, "new_text": texts["warehouse"]},
{"slide": 14, "shape_id": 14, "new_text": texts["live14"]},
{"slide": 15, "shape_id": 8, "new_text": texts["live15"]},
{"slide": 17, "shape_id": 15, "new_text": texts["member"]},
],
"replace_images": [],
"replace_tables": [],
"replace_charts": [],
"replace_native_charts": [],
}
out = root / "output" / "vip-report" / "render-ops.may-202605.auto-text.json"
out.write_text(json.dumps(ops, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"operations_path": str(out), "texts": texts}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import json
import math
import sys
from decimal import Decimal
from pathlib import Path
from typing import Any
import pymysql
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from runtime_config import add_config_source_args, load_runtime_config
from sync_cep_site_traffic_entry_assets import (
fetch_month_uv_series,
lfl_series,
month_label_to_number,
normalize_month_label,
)
from sync_cep_traffic_assets import build_table_values, query_month_row
from sync_cep_traffic_summary_assets import ENTRY_ORDER, fetch_entry_uv_map
TABLE_HEADERS = [
"Year",
"In-site Traffic",
"In-site Paid Traffic",
"In-site Paid %",
"In-site Organic Traffic",
"In-site Organic %",
"Off-site Traffic",
"Off-site Paid Traffic",
"Off-site Paid %",
"Off-site Organic Traffic",
"Off-site Organic %",
]
def round_up(value: int, step: int) -> int:
if value <= 0:
return step
return ((value + step - 1) // step) * step
def choose_value_axis(values_a: list[Decimal], values_b: list[Decimal]) -> tuple[int, int]:
max_volume = max(int(max(values_a or [Decimal("0")])), int(max(values_b or [Decimal("0")])))
y_step = 5000 if max_volume > 15000 else 2000
y_max = round_up(max_volume, y_step)
return y_max, y_step
def choose_lfl_axis(values: list[Decimal]) -> tuple[int, int, int]:
if not values:
return -20, 60, 20
min_v = float(min(values))
max_v = float(max(values))
span = max(abs(min_v), abs(max_v))
if span <= 80:
step = 20
elif span <= 300:
step = 50
else:
step = 100
low = int(math.floor(min_v / step) * step)
high = int(math.ceil(max_v / step) * step)
if low > 0:
low = 0
if high < 0:
high = 0
if high == low:
high += step
return low, high, step
def build_entry_rows(
cur_map: dict[str, Decimal], prev_map: dict[str, Decimal]
) -> list[dict[str, Any]]:
union_entries = set(cur_map) | set(prev_map)
ordered = [e for e in ENTRY_ORDER if e in union_entries]
extras = sorted(
[e for e in union_entries if e not in ordered],
key=lambda k: (cur_map.get(k, Decimal("0")) + prev_map.get(k, Decimal("0"))),
reverse=True,
)
ordered.extend(extras)
rows: list[dict[str, Any]] = []
for name in ordered:
rows.append(
{
"name": name,
"report_uv": int(cur_map.get(name, Decimal("0"))),
"compare_uv": int(prev_map.get(name, Decimal("0"))),
}
)
return rows
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build editable PPT operations for slide 12 (UV LFL) without image rendering."
)
add_config_source_args(parser, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
parser.add_argument("--report-month", default="", help="Month override, e.g. Jan / 1")
parser.add_argument("--report-year", type=int, default=0, help="Report year override")
parser.add_argument("--compare-year", type=int, default=0, help="Compare year override")
parser.add_argument("--output-ops", default="", help="Optional override output ops path")
return parser.parse_args()
def execute(
config: dict[str, Any],
*,
report_month: str = "",
report_year: int = 0,
compare_year: int = 0,
output_ops: str = "",
) -> dict[str, str]:
report_cfg = config.get("report", {})
month_label = normalize_month_label(report_month or report_cfg.get("month_cn", "一月"))
month_num = month_label_to_number(month_label)
report_year = int(report_year or report_cfg.get("year", 2026))
compare_year = int(compare_year or report_cfg.get("compare_year", report_year - 1))
mysql_cfg = config["mysql"]
conn = pymysql.connect(
host=mysql_cfg["host"],
port=int(mysql_cfg["port"]),
user=mysql_cfg["username"],
password=mysql_cfg["password"],
database=mysql_cfg["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
try:
cur_month_row = query_month_row(conn, year=report_year, month_num=month_num)
prev_month_row = query_month_row(conn, year=compare_year, month_num=month_num)
table_current, table_previous, table_lfl = build_table_values(
report_year=report_year,
compare_year=compare_year,
current=cur_month_row,
previous=prev_month_row,
)
cur_uv = fetch_month_uv_series(conn, year=report_year, month=month_num)
prev_uv = fetch_month_uv_series(conn, year=compare_year, month=month_num)
free_lfl = lfl_series(cur_uv["free"], prev_uv["free"])
paid_lfl = lfl_series(cur_uv["paid"], prev_uv["paid"])
cur_entry_map = fetch_entry_uv_map(conn, daily_month=f"{report_year}-{month_num:02d}")
prev_entry_map = fetch_entry_uv_map(conn, daily_month=f"{compare_year}-{month_num:02d}")
finally:
conn.close()
free_axis_max, free_axis_step = choose_value_axis(cur_uv["free"], prev_uv["free"])
paid_axis_max, paid_axis_step = choose_value_axis(cur_uv["paid"], prev_uv["paid"])
free_lfl_min, free_lfl_max, free_lfl_step = choose_lfl_axis(free_lfl)
paid_lfl_min, paid_lfl_max, paid_lfl_step = choose_lfl_axis(paid_lfl)
vip_workdir = Path(config["paths"]["workdir"]).resolve()
ops_path = Path(output_ops).resolve() if output_ops else (vip_workdir / "render-ops.uv-lfl-s12.editable.json")
ops_path.parent.mkdir(parents=True, exist_ok=True)
operations = {
"replace_text": [],
"replace_tables": [],
"replace_charts": [],
"replace_images": [],
"replace_native_charts": [
{
"type": "slide12_traffic_table",
"slide": 12,
"shape_id": 3,
"shape_name": "Table 17",
"target_box": {
"left": 1835149,
"top": 1557195,
"width": 16160753,
"height": 1465080,
},
"headers": TABLE_HEADERS,
"rows": [table_current, table_previous, table_lfl],
},
{
"type": "slide12_dual_line_lfl",
"slide": 12,
"shape_id": 5,
"shape_name": "Group 24",
"target_box": {
"left": 1987549,
"top": 3062843,
"width": 15887698,
"height": 3109300,
},
"month": month_num,
"report_year": report_year,
"compare_year": compare_year,
"panels": [
{
"title": "Organic Traffic LFL",
"current_name": f"{str(report_year)[-2:]} Organic Traffic",
"previous_name": f"{str(compare_year)[-2:]} Organic Traffic",
"current_values": [int(v) for v in cur_uv["free"]],
"previous_values": [int(v) for v in prev_uv["free"]],
"lfl_values": [round(float(v), 2) for v in free_lfl],
"value_axis_max": free_axis_max,
"value_axis_major_unit": free_axis_step,
"lfl_axis_min": free_lfl_min,
"lfl_axis_max": free_lfl_max,
"lfl_axis_major_unit": free_lfl_step,
},
{
"title": "Paid Traffic LFL",
"current_name": f"{str(report_year)[-2:]} Paid Traffic",
"previous_name": f"{str(compare_year)[-2:]} Paid Traffic",
"current_values": [int(v) for v in cur_uv["paid"]],
"previous_values": [int(v) for v in prev_uv["paid"]],
"lfl_values": [round(float(v), 2) for v in paid_lfl],
"value_axis_max": paid_axis_max,
"value_axis_major_unit": paid_axis_step,
"lfl_axis_min": paid_lfl_min,
"lfl_axis_max": paid_lfl_max,
"lfl_axis_major_unit": paid_lfl_step,
},
],
},
{
"type": "slide12_entry_mix",
"slide": 12,
"shape_id": 8,
"shape_name": "Group 25",
"target_box": {
"left": 1987549,
"top": 6093856,
"width": 16160750,
"height": 2729245,
},
"report_year": report_year,
"compare_year": compare_year,
"entries": build_entry_rows(cur_entry_map, prev_entry_map),
},
],
}
ops_path.write_text(json.dumps(operations, ensure_ascii=False, indent=2), encoding="utf-8")
return {"operations_path": str(ops_path)}
def main() -> None:
args = parse_args()
config = load_runtime_config(args, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
result = execute(
config,
report_month=args.report_month,
report_year=args.report_year,
compare_year=args.compare_year,
output_ops=args.output_ops,
)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import calendar
import json
import re
import sys
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Any
import pymysql
try:
import openpyxl
from openpyxl.worksheet import _reader as ws_reader
except Exception: # optional offline Excel fallback
openpyxl = None
ws_reader = None
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from runtime_config import add_config_source_args, load_runtime_config
TABLE_SESSION = "cep_live_session_data"
TABLE_GOODS = "cep_live_goods_effect"
MONTH_ABBR = {
1: "Jan",
2: "Feb",
3: "Mar",
4: "Apr",
5: "May",
6: "Jun",
7: "Jul",
8: "Aug",
9: "Sep",
10: "Oct",
11: "Nov",
12: "Dec",
}
CATEGORY_ORDER = ["箱包", "鞋", "配饰"]
TOP5_NOTE_RULES = [
"ESS独家5折",
"ESS独家7折",
"ESS独家",
"正价RP",
"礼赠款",
]
@dataclass(frozen=True)
class MonthKey:
year: int
month: int
@property
def ym(self) -> str:
return f"{self.year}-{self.month:02d}"
@property
def label(self) -> str:
return MONTH_ABBR[self.month]
def parse_month_number(raw: Any) -> int:
if raw is None:
return 1
if isinstance(raw, int):
return min(12, max(1, raw))
text = str(raw).strip()
m = re.search(r"(1[0-2]|0?[1-9])", text)
if m:
return int(m.group(1))
return 1
def previous_month(m: MonthKey) -> MonthKey:
if m.month == 1:
return MonthKey(m.year - 1, 12)
return MonthKey(m.year, m.month - 1)
def same_month_last_year(m: MonthKey) -> MonthKey:
return MonthKey(m.year - 1, m.month)
def to_decimal(value: Any) -> Decimal:
if value is None:
return Decimal("0")
text = str(value).strip().replace(",", "")
if not text:
return Decimal("0")
try:
return Decimal(text)
except Exception:
return Decimal("0")
def fmt_int(v: Decimal) -> str:
return f"{int(v.quantize(Decimal('1'), rounding=ROUND_HALF_UP)):,}"
def fmt_amount(v: Decimal) -> str:
return f"{v.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP):,}"
def fmt_pct(v: Decimal, digits: int = 0, signed: bool = False) -> str:
q = Decimal("1") if digits == 0 else Decimal("1." + "0" * digits)
pct = (v * Decimal("100")).quantize(q, rounding=ROUND_HALF_UP)
sign = ""
if signed and pct > 0:
sign = "+"
return f"{sign}{pct}%"
def ratio(curr: Decimal, prev: Decimal) -> Decimal:
if prev == 0:
return Decimal("0")
return (curr - prev) / prev
def share(part: Decimal, total: Decimal) -> Decimal:
if total == 0:
return Decimal("0")
return part / total
def extract_article(title: str) -> str:
m = re.search(r"(CK\d-[0-9]{8}(?:-[0-9])?)", title.upper())
return m.group(1) if m else ""
def normalize_note(text: str) -> str:
for key in TOP5_NOTE_RULES:
if key in text:
return key
return ""
def choose_sales_axis(values: list[Decimal]) -> tuple[int, int]:
max_v = max([int(v) for v in values] or [0])
if max_v <= 30000:
step = 5000
elif max_v <= 90000:
step = 10000
else:
step = 20000
y_max = ((max_v + step - 1) // step) * step
if y_max <= 0:
y_max = step * 6
return y_max, step
def choose_conv_axis(values: list[Decimal]) -> tuple[float, float]:
max_v = float(max(values or [Decimal("0")]))
if max_v <= 0.08:
y_max = 0.10
elif max_v <= 0.12:
y_max = 0.15
elif max_v <= 0.18:
y_max = 0.20
else:
y_max = min(0.40, (int(max_v * 100 / 5) + 1) * 0.05)
return y_max, 0.02
def fetch_live_session_agg(conn: pymysql.connections.Connection, month_key: MonthKey) -> dict[str, Decimal]:
sql = f"""
SELECT
SUM(IFNULL(accumulated_viewers, 0)) AS accumulated_viewers,
SUM(IFNULL(purchasers_total, 0)) AS purchasers_total,
SUM(IFNULL(live_period_sales_total, 0)) AS live_period_sales_total,
SUM(IFNULL(guided_sales_total, 0)) AS guided_sales_total,
SUM(IFNULL(brand_customers_total, 0)) AS brand_customers_total,
SUM(IFNULL(brand_new_customers_total, 0)) AS brand_new_customers_total,
SUM(IFNULL(new_brand_members, 0)) AS new_brand_members,
SUM(IFNULL(live_sales_penetration_rate, 0) * IFNULL(accumulated_viewers, 0)) AS weighted_penetration,
SUM(IFNULL(accumulated_viewers, 0)) AS weighted_base
FROM `{TABLE_SESSION}`
WHERE data_month = %s
"""
with conn.cursor() as cur:
cur.execute(sql, (month_key.ym,))
row = cur.fetchone() or {}
viewers = to_decimal(row.get("accumulated_viewers"))
purchasers = to_decimal(row.get("purchasers_total"))
brand_customers = to_decimal(row.get("brand_customers_total"))
brand_new = to_decimal(row.get("brand_new_customers_total"))
weighted_pen = to_decimal(row.get("weighted_penetration"))
weighted_base = to_decimal(row.get("weighted_base"))
live_pen = (weighted_pen / weighted_base) if weighted_base > 0 else Decimal("0")
conversion = (purchasers / viewers) if viewers > 0 else Decimal("0")
new_ratio = (brand_new / brand_customers) if brand_customers > 0 else Decimal("0")
return {
"accumulated_viewers": viewers,
"live_penetration": live_pen,
"purchasers_total": purchasers,
"conversion": conversion,
"live_period_sales_total": to_decimal(row.get("live_period_sales_total")),
"guided_sales_total": to_decimal(row.get("guided_sales_total")),
"brand_customers_total": brand_customers,
"brand_new_customers_total": brand_new,
"new_customer_ratio": new_ratio,
"new_brand_members": to_decimal(row.get("new_brand_members")),
}
def build_slide14_summary_rows(prev_key: MonthKey, prev: dict[str, Decimal], curr_key: MonthKey, curr: dict[str, Decimal]) -> tuple[list[str], list[list[str]]]:
headers = [
"Month",
"Accum. Viewers",
"Live Penetration",
"Purchasers",
"Conversion",
"Live Sales",
"Guided Sales",
"Brand Customers",
"Brand New Customers",
"New Customer Ratio",
"New Brand Members",
]
prev_row = [
prev_key.label,
fmt_int(prev["accumulated_viewers"]),
fmt_pct(prev["live_penetration"], 2),
fmt_int(prev["purchasers_total"]),
fmt_pct(prev["conversion"], 2),
fmt_int(prev["live_period_sales_total"]),
fmt_int(prev["guided_sales_total"]),
fmt_int(prev["brand_customers_total"]),
fmt_int(prev["brand_new_customers_total"]),
fmt_pct(prev["new_customer_ratio"], 0),
fmt_int(prev["new_brand_members"]),
]
curr_row = [
curr_key.label,
fmt_int(curr["accumulated_viewers"]),
fmt_pct(curr["live_penetration"], 2),
fmt_int(curr["purchasers_total"]),
fmt_pct(curr["conversion"], 2),
fmt_int(curr["live_period_sales_total"]),
fmt_int(curr["guided_sales_total"]),
fmt_int(curr["brand_customers_total"]),
fmt_int(curr["brand_new_customers_total"]),
fmt_pct(curr["new_customer_ratio"], 0),
fmt_int(curr["new_brand_members"]),
]
lfl_row = [
"LFL",
fmt_pct(ratio(curr["accumulated_viewers"], prev["accumulated_viewers"]), 0, signed=True),
fmt_pct(ratio(curr["live_penetration"], prev["live_penetration"]), 0, signed=True),
fmt_pct(ratio(curr["purchasers_total"], prev["purchasers_total"]), 0, signed=True),
fmt_pct(ratio(curr["conversion"], prev["conversion"]), 0, signed=True),
fmt_pct(ratio(curr["live_period_sales_total"], prev["live_period_sales_total"]), 0, signed=True),
fmt_pct(ratio(curr["guided_sales_total"], prev["guided_sales_total"]), 0, signed=True),
fmt_pct(ratio(curr["brand_customers_total"], prev["brand_customers_total"]), 0, signed=True),
fmt_pct(ratio(curr["brand_new_customers_total"], prev["brand_new_customers_total"]), 0, signed=True),
fmt_pct(ratio(curr["new_customer_ratio"], prev["new_customer_ratio"]), 0, signed=True),
fmt_pct(ratio(curr["new_brand_members"], prev["new_brand_members"]), 0, signed=True),
]
return headers, [prev_row, curr_row, lfl_row]
def fetch_live_session_daily(conn: pymysql.connections.Connection, month_key: MonthKey) -> tuple[list[int], list[Decimal], list[Decimal]]:
sql = f"""
SELECT
biz_date,
SUM(IFNULL(live_period_sales_total, 0)) AS sales_total,
SUM(IFNULL(live_sales_penetration_rate, 0) * IFNULL(accumulated_viewers, 0)) AS weighted_penetration,
SUM(IFNULL(accumulated_viewers, 0)) AS weighted_base
FROM `{TABLE_SESSION}`
WHERE data_month = %s
GROUP BY biz_date
ORDER BY biz_date
"""
day_map: dict[int, tuple[Decimal, Decimal]] = {}
with conn.cursor() as cur:
cur.execute(sql, (month_key.ym,))
rows = cur.fetchall() or []
for row in rows:
biz_date = str(row.get("biz_date") or "")
if len(biz_date) >= 10 and biz_date[8:10].isdigit():
day = int(biz_date[8:10])
else:
continue
sales = to_decimal(row.get("sales_total"))
weighted_pen = to_decimal(row.get("weighted_penetration"))
weighted_base = to_decimal(row.get("weighted_base"))
penetration = weighted_pen / weighted_base if weighted_base > 0 else Decimal("0")
day_map[day] = (sales, penetration)
days_in_month = calendar.monthrange(month_key.year, month_key.month)[1]
day_list: list[int] = []
sales_values: list[Decimal] = []
conv_values: list[Decimal] = []
for day in range(1, days_in_month + 1):
s, c = day_map.get(day, (Decimal("0"), Decimal("0")))
day_list.append(day)
sales_values.append(s)
conv_values.append(c)
return day_list, sales_values, conv_values
def fetch_goods_category_rows(conn: pymysql.connections.Connection, month_key: MonthKey) -> dict[str, dict[str, Decimal]]:
sql = f"""
SELECT
CASE
WHEN category_level_1 IN ('配饰', '珠宝首饰') THEN '配饰'
ELSE category_level_1
END AS cat,
COUNT(DISTINCT product_spu_id) AS article_cnt,
SUM(IFNULL(item_count_total, 0)) AS qty,
SUM(IFNULL(sales_total, 0)) AS sales,
SUM(IFNULL(brand_new_customer_count, 0)) AS brand_new_customers
FROM `{TABLE_GOODS}`
WHERE data_month = %s
GROUP BY cat
"""
out: dict[str, dict[str, Decimal]] = {}
with conn.cursor() as cur:
cur.execute(sql, (month_key.ym,))
rows = cur.fetchall() or []
for row in rows:
cat = str(row.get("cat") or "").strip() or "未分类"
out[cat] = {
"article_cnt": to_decimal(row.get("article_cnt")),
"qty": to_decimal(row.get("qty")),
"sales": to_decimal(row.get("sales")),
"brand_new_customers": to_decimal(row.get("brand_new_customers")),
}
return out
def build_slide15_summary_rows(
curr_rows: dict[str, dict[str, Decimal]],
prev_rows: dict[str, dict[str, Decimal]],
) -> tuple[list[str], list[list[str]]]:
for c in CATEGORY_ORDER:
curr_rows.setdefault(c, {"article_cnt": Decimal("0"), "qty": Decimal("0"), "sales": Decimal("0"), "brand_new_customers": Decimal("0")})
prev_rows.setdefault(c, {"article_cnt": Decimal("0"), "qty": Decimal("0"), "sales": Decimal("0"), "brand_new_customers": Decimal("0")})
ttl_curr = {
"article_cnt": sum(curr_rows[c]["article_cnt"] for c in CATEGORY_ORDER),
"qty": sum(curr_rows[c]["qty"] for c in CATEGORY_ORDER),
"sales": sum(curr_rows[c]["sales"] for c in CATEGORY_ORDER),
"brand_new_customers": sum(curr_rows[c]["brand_new_customers"] for c in CATEGORY_ORDER),
}
ttl_prev = {
"article_cnt": sum(prev_rows[c]["article_cnt"] for c in CATEGORY_ORDER),
"qty": sum(prev_rows[c]["qty"] for c in CATEGORY_ORDER),
"sales": sum(prev_rows[c]["sales"] for c in CATEGORY_ORDER),
"brand_new_customers": sum(prev_rows[c]["brand_new_customers"] for c in CATEGORY_ORDER),
}
headers = [
"Cat",
"Article",
"Article%",
"Live QTY",
"QTY%",
"QTY LFL",
"Sales",
"Sales%",
"Sales LFL",
"New Customers",
"New Cust %",
"New Cust LFL",
"ASP",
"ASP LFL",
]
def row_values(cat: str, curr: dict[str, Decimal], prev: dict[str, Decimal]) -> list[str]:
asp_curr = curr["sales"] / curr["qty"] if curr["qty"] > 0 else Decimal("0")
asp_prev = prev["sales"] / prev["qty"] if prev["qty"] > 0 else Decimal("0")
return [
cat,
f"{int(curr['article_cnt'].quantize(Decimal('1'), rounding=ROUND_HALF_UP))}",
fmt_pct(share(curr["article_cnt"], ttl_curr["article_cnt"]), 0),
f"{int(curr['qty'].quantize(Decimal('1'), rounding=ROUND_HALF_UP))}",
fmt_pct(share(curr["qty"], ttl_curr["qty"]), 0),
fmt_pct(ratio(curr["qty"], prev["qty"]), 0, signed=True),
fmt_amount(curr["sales"]),
fmt_pct(share(curr["sales"], ttl_curr["sales"]), 0),
fmt_pct(ratio(curr["sales"], prev["sales"]), 0, signed=True),
f"{int(curr['brand_new_customers'].quantize(Decimal('1'), rounding=ROUND_HALF_UP))}",
fmt_pct(share(curr["brand_new_customers"], ttl_curr["brand_new_customers"]), 0),
fmt_pct(ratio(curr["brand_new_customers"], prev["brand_new_customers"]), 0, signed=True),
f"{int(asp_curr.quantize(Decimal('1'), rounding=ROUND_HALF_UP))}",
fmt_pct(ratio(asp_curr, asp_prev), 0, signed=True),
]
rows: list[list[str]] = []
for c in CATEGORY_ORDER:
rows.append(row_values(c, curr_rows[c], prev_rows[c]))
rows.append(row_values("TTL", ttl_curr, ttl_prev))
return headers, rows
def fetch_goods_mix_rows(conn: pymysql.connections.Connection, month_key: MonthKey) -> tuple[list[tuple[str, Decimal]], list[tuple[str, Decimal]]]:
sql = f"""
SELECT
CASE
WHEN category_level_1 IN ('配饰', '珠宝首饰') THEN '配饰'
ELSE category_level_1
END AS level1,
COALESCE(NULLIF(TRIM(category_level_3), ''), '未分类') AS level3,
SUM(IFNULL(item_count_total, 0)) AS qty
FROM `{TABLE_GOODS}`
WHERE data_month = %s
GROUP BY level1, level3
HAVING SUM(IFNULL(item_count_total, 0)) > 0
"""
level1_totals: dict[str, Decimal] = {}
level3_totals: dict[str, Decimal] = {}
with conn.cursor() as cur:
cur.execute(sql, (month_key.ym,))
rows = cur.fetchall() or []
for row in rows:
l1 = str(row.get("level1") or "未分类")
l3 = str(row.get("level3") or "未分类")
qty = to_decimal(row.get("qty"))
level1_totals[l1] = level1_totals.get(l1, Decimal("0")) + qty
level3_totals[l3] = level3_totals.get(l3, Decimal("0")) + qty
level1_sorted = sorted(level1_totals.items(), key=lambda x: x[1], reverse=True)
level3_sorted = sorted(level3_totals.items(), key=lambda x: x[1], reverse=True)
return level1_sorted, level3_sorted
VIPDATA_DIR = Path(r"C:\vipdata")
def patch_openpyxl_column_dimension_error() -> None:
if ws_reader is None:
return
orig = ws_reader.WorkSheetParser.parse_column_dimensions
if getattr(orig, "_vip_report_patched", False):
return
def patched(self: Any, col: Any) -> Any:
try:
return orig(self, col)
except ValueError as e:
if "Invalid column index" in str(e):
return None
raise
patched._vip_report_patched = True # type: ignore[attr-defined]
ws_reader.WorkSheetParser.parse_column_dimensions = patched
def excel_cell(row: dict[str, Any], name: str) -> Any:
return row.get(name)
LIVE_COL_DATE = "\u65e5\u671f"
LIVE_COL_VIEWERS = "\u7d2f\u8ba1\u89c2\u770b\u4eba\u6570"
LIVE_COL_PENETRATION = "\u76f4\u64ad\u9500\u552e\u6e17\u900f\u7387"
LIVE_COL_PURCHASERS = "\u8d2d\u4e70\u4eba\u6570(\u603b)"
LIVE_COL_LIVE_SALES = "\u76f4\u64ad\u65f6\u6bb5\u9500\u552e\u989d(\u603b)"
LIVE_COL_GUIDED_SALES = "\u603b\u5f15\u5bfc\u9500\u552e(\u603b)"
LIVE_COL_BRAND_CUSTOMERS = "\u54c1\u724c\u5ba2\u6237\u6570(\u603b)"
LIVE_COL_BRAND_NEW = "\u54c1\u724c\u65b0\u5ba2\u6570(\u603b)"
LIVE_COL_NEW_MEMBERS = "\u65b0\u589e\u54c1\u724c\u5165\u4f1a\u4eba\u6570"
GOODS_COL_LEVEL1 = "\u4e00\u7ea7\u5206\u7c7b"
GOODS_COL_LEVEL3 = "\u4e09\u7ea7\u5206\u7c7b"
GOODS_COL_SPU = "\u5546\u54c1spuID"
GOODS_COL_TITLE = "\u5546\u54c1\u6807\u9898"
GOODS_COL_QTY = "\u8d2d\u4e70\u4ef6\u6570(\u603b)"
GOODS_COL_SALES = "\u9500\u552e\u989d(\u603b)"
GOODS_COL_BRAND_NEW = "\u54c1\u724c\u65b0\u5ba2\u6570"
CATEGORY_BAGS = "\u7bb1\u5305"
CATEGORY_SHOES = "\u978b"
CATEGORY_ACCESSORIES = "\u914d\u9970"
CATEGORY_JEWELRY = "\u73e0\u5b9d\u9996\u9970"
CATEGORY_UNKNOWN = "\u672a\u5206\u7c7b"
def excel_pct_to_ratio(value: Any) -> Decimal:
if value is None:
return Decimal("0")
text = str(value).strip().replace(",", "")
if not text:
return Decimal("0")
if text.endswith("%"):
text = text[:-1]
return to_decimal(text) / Decimal("100")
number = to_decimal(text)
return number / Decimal("100") if number > 1 else number
def excel_date_to_day(value: Any) -> int | None:
if value is None:
return None
if hasattr(value, "day"):
return int(value.day)
text = str(value).strip()
m = re.search(r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})", text)
if m:
return int(m.group(3))
return None
def read_excel_rows(path: Path) -> list[dict[str, Any]]:
if openpyxl is None:
raise RuntimeError("openpyxl is required for offline Excel fallback")
patch_openpyxl_column_dimension_error()
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
iterator = ws.iter_rows(values_only=True)
headers = next(iterator, None)
if not headers:
return []
names = [str(h).strip() if h is not None else "" for h in headers]
rows: list[dict[str, Any]] = []
for raw in iterator:
row = {names[i]: raw[i] if i < len(raw) else None for i in range(len(names))}
rows.append(row)
return rows
def pick_live_excel(month_key: MonthKey) -> Path:
prefix = f"{month_key.year}{month_key.month:02d}_"
candidates = [
p for p in VIPDATA_DIR.glob(f"{prefix}*.xlsx")
if "goods_effect" not in p.name and p.stat().st_size >= 100000
]
if not candidates:
raise RuntimeError(f"No live-session Excel found for {month_key.ym} under {VIPDATA_DIR}")
return sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True)[0]
def pick_goods_excel(month_key: MonthKey) -> Path:
prefix = f"{month_key.year}{month_key.month:02d}_goods_effect_"
candidates = sorted(VIPDATA_DIR.glob(f"{prefix}*MID*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True)
if not candidates:
candidates = sorted(VIPDATA_DIR.glob(f"{prefix}*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True)
if not candidates:
raise RuntimeError(f"No goods-effect Excel found for {month_key.ym} under {VIPDATA_DIR}")
return candidates[0]
def fetch_live_session_agg_from_excel(month_key: MonthKey) -> dict[str, Decimal]:
rows = read_excel_rows(pick_live_excel(month_key))
viewers = Decimal("0")
purchasers = Decimal("0")
live_sales = Decimal("0")
guided_sales = Decimal("0")
brand_customers = Decimal("0")
brand_new = Decimal("0")
new_members = Decimal("0")
weighted_pen = Decimal("0")
weighted_base = Decimal("0")
for row in rows:
v = to_decimal(excel_cell(row, LIVE_COL_VIEWERS))
pen = excel_pct_to_ratio(excel_cell(row, LIVE_COL_PENETRATION))
viewers += v
purchasers += to_decimal(excel_cell(row, LIVE_COL_PURCHASERS))
live_sales += to_decimal(excel_cell(row, LIVE_COL_LIVE_SALES))
guided_sales += to_decimal(excel_cell(row, LIVE_COL_GUIDED_SALES))
brand_customers += to_decimal(excel_cell(row, LIVE_COL_BRAND_CUSTOMERS))
brand_new += to_decimal(excel_cell(row, LIVE_COL_BRAND_NEW))
new_members += to_decimal(excel_cell(row, LIVE_COL_NEW_MEMBERS))
weighted_pen += pen * v
weighted_base += v
return {
"accumulated_viewers": viewers,
"live_penetration": weighted_pen / weighted_base if weighted_base > 0 else Decimal("0"),
"purchasers_total": purchasers,
"conversion": purchasers / viewers if viewers > 0 else Decimal("0"),
"live_period_sales_total": live_sales,
"guided_sales_total": guided_sales,
"brand_customers_total": brand_customers,
"brand_new_customers_total": brand_new,
"new_customer_ratio": brand_new / brand_customers if brand_customers > 0 else Decimal("0"),
"new_brand_members": new_members,
}
def fetch_live_session_daily_from_excel(month_key: MonthKey) -> tuple[list[int], list[Decimal], list[Decimal]]:
rows = read_excel_rows(pick_live_excel(month_key))
day_totals: dict[int, dict[str, Decimal]] = {}
for row in rows:
day = excel_date_to_day(excel_cell(row, LIVE_COL_DATE))
if not day:
continue
bucket = day_totals.setdefault(day, {"sales": Decimal("0"), "weighted_pen": Decimal("0"), "viewers": Decimal("0")})
viewers = to_decimal(excel_cell(row, LIVE_COL_VIEWERS))
pen = excel_pct_to_ratio(excel_cell(row, LIVE_COL_PENETRATION))
bucket["sales"] += to_decimal(excel_cell(row, LIVE_COL_LIVE_SALES))
bucket["weighted_pen"] += pen * viewers
bucket["viewers"] += viewers
days_in_month = calendar.monthrange(month_key.year, month_key.month)[1]
day_list: list[int] = []
sales_values: list[Decimal] = []
conv_values: list[Decimal] = []
for day in range(1, days_in_month + 1):
bucket = day_totals.get(day, {"sales": Decimal("0"), "weighted_pen": Decimal("0"), "viewers": Decimal("0")})
day_list.append(day)
sales_values.append(bucket["sales"])
conv_values.append(bucket["weighted_pen"] / bucket["viewers"] if bucket["viewers"] > 0 else Decimal("0"))
return day_list, sales_values, conv_values
def normalize_category_level1(value: Any) -> str:
cat = str(value or "").strip() or CATEGORY_UNKNOWN
return CATEGORY_ACCESSORIES if cat in {CATEGORY_ACCESSORIES, CATEGORY_JEWELRY} else cat
def iter_goods_excel_rows(month_key: MonthKey) -> list[dict[str, Any]]:
return read_excel_rows(pick_goods_excel(month_key))
def fetch_goods_category_rows_from_excel(month_key: MonthKey) -> dict[str, dict[str, Decimal]]:
out: dict[str, dict[str, Decimal]] = {}
for row in iter_goods_excel_rows(month_key):
cat = normalize_category_level1(excel_cell(row, GOODS_COL_LEVEL1))
bucket = out.setdefault(cat, {"articles": set(), "qty": Decimal("0"), "sales": Decimal("0"), "brand_new_customers": Decimal("0")})
article = str(excel_cell(row, GOODS_COL_SPU) or "").strip()
if article:
bucket["articles"].add(article) # type: ignore[union-attr]
bucket["qty"] += to_decimal(excel_cell(row, GOODS_COL_QTY))
bucket["sales"] += to_decimal(excel_cell(row, GOODS_COL_SALES))
bucket["brand_new_customers"] += to_decimal(excel_cell(row, GOODS_COL_BRAND_NEW))
converted: dict[str, dict[str, Decimal]] = {}
for cat, values in out.items():
converted[cat] = {
"article_cnt": Decimal(len(values.get("articles", set()))),
"qty": values["qty"],
"sales": values["sales"],
"brand_new_customers": values["brand_new_customers"],
}
return converted
def fetch_goods_mix_rows_from_excel(month_key: MonthKey) -> tuple[list[tuple[str, Decimal]], list[tuple[str, Decimal]]]:
level1_totals: dict[str, Decimal] = {}
level3_totals: dict[str, Decimal] = {}
for row in iter_goods_excel_rows(month_key):
qty = to_decimal(excel_cell(row, GOODS_COL_QTY))
if qty <= 0:
continue
l1 = normalize_category_level1(excel_cell(row, GOODS_COL_LEVEL1))
l3 = str(excel_cell(row, GOODS_COL_LEVEL3) or CATEGORY_UNKNOWN).strip() or CATEGORY_UNKNOWN
level1_totals[l1] = level1_totals.get(l1, Decimal("0")) + qty
level3_totals[l3] = level3_totals.get(l3, Decimal("0")) + qty
return (
sorted(level1_totals.items(), key=lambda x: x[1], reverse=True),
sorted(level3_totals.items(), key=lambda x: x[1], reverse=True),
)
def fetch_goods_top5_from_excel(month_key: MonthKey) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
bag_map: dict[str, int] = {}
shoe_map: dict[str, int] = {}
bag_note_map: dict[str, dict[str, int]] = {}
shoe_note_map: dict[str, dict[str, int]] = {}
for row in iter_goods_excel_rows(month_key):
cat = normalize_category_level1(excel_cell(row, GOODS_COL_LEVEL1))
title = str(excel_cell(row, GOODS_COL_TITLE) or "")
qty = int(to_decimal(excel_cell(row, GOODS_COL_QTY)))
if qty <= 0:
continue
article = extract_article(title)
if not article:
continue
note = normalize_note(title)
if cat == CATEGORY_BAGS:
bag_map[article] = bag_map.get(article, 0) + qty
if note:
bag_note_map.setdefault(article, {})[note] = bag_note_map.setdefault(article, {}).get(note, 0) + qty
elif cat == CATEGORY_SHOES:
shoe_map[article] = shoe_map.get(article, 0) + qty
if note:
shoe_note_map.setdefault(article, {})[note] = shoe_note_map.setdefault(article, {}).get(note, 0) + qty
def to_top_rows(base: dict[str, int], notes: dict[str, dict[str, int]]) -> list[dict[str, Any]]:
rows_out: list[dict[str, Any]] = []
for article, qty in sorted(base.items(), key=lambda x: x[1], reverse=True)[:5]:
note = ""
if article in notes and notes[article]:
note = sorted(notes[article].items(), key=lambda kv: kv[1], reverse=True)[0][0]
rows_out.append({"article": article, "qty": qty, "note": note})
return rows_out
return to_top_rows(bag_map, bag_note_map), to_top_rows(shoe_map, shoe_note_map)
def fetch_goods_top5(conn: pymysql.connections.Connection, month_key: MonthKey) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sql = f"""
SELECT
category_level_1 AS cat,
product_title,
IFNULL(item_count_total, 0) AS qty
FROM `{TABLE_GOODS}`
WHERE data_month = %s
"""
bag_map: dict[str, int] = {}
shoe_map: dict[str, int] = {}
bag_note_map: dict[str, dict[str, int]] = {}
shoe_note_map: dict[str, dict[str, int]] = {}
with conn.cursor() as cur:
cur.execute(sql, (month_key.ym,))
rows = cur.fetchall() or []
for row in rows:
cat = str(row.get("cat") or "").strip()
title = str(row.get("product_title") or "")
qty = int(to_decimal(row.get("qty")))
if qty <= 0:
continue
article = extract_article(title)
if not article:
continue
side = ""
if cat == "箱包":
side = "bags"
elif cat == "鞋":
side = "shoes"
else:
continue
note = normalize_note(title)
if side == "bags":
bag_map[article] = bag_map.get(article, 0) + qty
if note:
bag_note_map.setdefault(article, {})
bag_note_map[article][note] = bag_note_map[article].get(note, 0) + qty
else:
shoe_map[article] = shoe_map.get(article, 0) + qty
if note:
shoe_note_map.setdefault(article, {})
shoe_note_map[article][note] = shoe_note_map[article].get(note, 0) + qty
def to_top_rows(base: dict[str, int], notes: dict[str, dict[str, int]]) -> list[dict[str, Any]]:
top = sorted(base.items(), key=lambda x: x[1], reverse=True)[:5]
rows_out: list[dict[str, Any]] = []
for article, qty in top:
note = ""
if article in notes and notes[article]:
note = sorted(notes[article].items(), key=lambda kv: kv[1], reverse=True)[0][0]
rows_out.append({"article": article, "qty": qty, "note": note})
return rows_out
return to_top_rows(bag_map, bag_note_map), to_top_rows(shoe_map, shoe_note_map)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build editable PPT operations for slides 14 and 15."
)
add_config_source_args(parser, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
parser.add_argument("--report-month", default="", help="Month override, e.g. 3 / Mar")
parser.add_argument("--report-year", type=int, default=0, help="Report year override")
parser.add_argument("--output-ops", default="", help="Optional output ops path")
parser.add_argument("--slides", default="14,15", help="Comma-separated subset: 14,15")
return parser.parse_args()
def find_local_product_image(workdir: Path, article: str) -> str:
if not article:
return ""
search_roots = [
workdir / "assets" / "top-products",
workdir / "vip-report" / "assets" / "top-products",
workdir / "assets" / "campaign-s11" / "top10-images",
]
suffixes = (".png", ".jpg", ".jpeg")
for root in search_roots:
if not root.exists():
continue
for suffix in suffixes:
matches = sorted(root.glob(f"*{article}*{suffix}"))
if matches:
return str(matches[0].resolve())
return ""
def attach_product_images(rows: list[dict[str, Any]], workdir: Path) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in rows:
copied = dict(row)
copied["image_path"] = find_local_product_image(workdir, str(copied.get("article") or ""))
out.append(copied)
return out
def execute(
config: dict[str, Any],
*,
report_month: str = "",
report_year: int = 0,
output_ops: str = "",
slides: str = "14,15",
) -> dict[str, str]:
report_cfg = config.get("report", {})
year = int(report_year or report_cfg.get("year", 2026))
month = parse_month_number(report_month or report_cfg.get("month_cn", "1"))
curr_key = MonthKey(year, month)
prev_key = previous_month(curr_key)
slide15_curr_key = curr_key
slide15_compare_key = same_month_last_year(curr_key)
requested = {token.strip() for token in slides.split(",") if token.strip()}
include14 = "14" in requested
include15 = "15" in requested
if not include14 and not include15:
raise RuntimeError("No valid slides requested. Use --slides 14,15")
try:
mysql_cfg = config["mysql"]
mysql_user = mysql_cfg.get("username") or mysql_cfg.get("user")
conn = pymysql.connect(
host=mysql_cfg["host"],
port=int(mysql_cfg["port"]),
user=mysql_user,
password=mysql_cfg["password"],
database=mysql_cfg["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
try:
session_curr = fetch_live_session_agg(conn, curr_key)
session_prev = fetch_live_session_agg(conn, prev_key)
days, sales_values, penetration_values = fetch_live_session_daily(conn, curr_key)
goods_curr = fetch_goods_category_rows(conn, slide15_curr_key)
goods_prev = fetch_goods_category_rows(conn, slide15_compare_key)
compare_month = slide15_compare_key
level1_mix, level3_mix = fetch_goods_mix_rows(conn, curr_key)
bags_top5, shoes_top5 = fetch_goods_top5(conn, slide15_curr_key)
finally:
conn.close()
except Exception as db_error:
print(f"WARN: MySQL unavailable, using offline Excel fallback: {db_error}", file=sys.stderr)
session_curr = fetch_live_session_agg_from_excel(curr_key)
session_prev = fetch_live_session_agg_from_excel(prev_key)
days, sales_values, penetration_values = fetch_live_session_daily_from_excel(curr_key)
goods_curr = fetch_goods_category_rows_from_excel(slide15_curr_key)
goods_prev = fetch_goods_category_rows_from_excel(slide15_compare_key)
compare_month = slide15_compare_key
level1_mix, level3_mix = fetch_goods_mix_rows_from_excel(curr_key)
bags_top5, shoes_top5 = fetch_goods_top5_from_excel(slide15_curr_key)
s14_headers, s14_rows = build_slide14_summary_rows(prev_key, session_prev, curr_key, session_curr)
sales_axis_max, sales_axis_step = choose_sales_axis(sales_values)
penetration_axis_max, penetration_axis_step = choose_conv_axis(penetration_values)
s15_headers, s15_rows = build_slide15_summary_rows(goods_curr, goods_prev)
level1_total = sum([v for _, v in level1_mix] or [Decimal("0")])
level3_total = sum([v for _, v in level3_mix] or [Decimal("0")])
level3_top = level3_mix[:5]
level3_tail = level3_mix[5:15]
vip_workdir = Path(config["paths"]["workdir"]).resolve()
ops_path = Path(output_ops).resolve() if output_ops else (vip_workdir / "render-ops.flow14-15.editable.json")
ops_path.parent.mkdir(parents=True, exist_ok=True)
native_ops: list[dict[str, Any]] = []
if include14:
native_ops.extend(
[
{
"type": "slide14_live_session_summary_table",
"slide": 14,
"shape_id": 4,
"shape_name": "Table 1",
"target_box": {
"left": 1341272,
"top": 1883910,
"width": 17421552,
"height": 1286012,
},
"headers": s14_headers,
"rows": s14_rows,
},
{
"type": "slide14_live_session_trend",
"slide": 14,
"shape_id": 7,
"shape_name": "Chart 4",
"target_box": {
"left": 3350681,
"top": 3269323,
"width": 13402732,
"height": 3134677,
},
"month": curr_key.month,
"days": days,
"sales_values": [float(v) for v in sales_values],
"penetration_values": [round(float(v), 6) for v in penetration_values],
"sales_axis_max": sales_axis_max,
"sales_axis_major_unit": sales_axis_step,
"penetration_axis_max": penetration_axis_max,
"penetration_axis_major_unit": penetration_axis_step,
},
{
"type": "slide14_goods_category_mix",
"slide": 14,
"shape_id": 10,
"shape_name": "Group 12",
"target_box": {
"left": 683261,
"top": 6920612,
"width": 10262215,
"height": 4023309,
},
"level1_rows": [
{
"name": name,
"value": float(value),
"share": float(share(value, level1_total)),
}
for name, value in level1_mix
],
"level3_top_rows": [
{
"name": name,
"value": float(value),
"share": float(share(value, level3_total)),
}
for name, value in level3_top
],
"level3_tail_rows": [
{
"name": name,
"value": float(value),
"share": float(share(value, level3_total)),
}
for name, value in level3_tail
],
},
]
)
if include15:
native_ops.extend(
[
{
"type": "slide15_goods_summary_table",
"slide": 15,
"shape_id": 5,
"shape_name": "Table 2",
"target_box": {
"left": 2171701,
"top": 1875631,
"width": 15995648,
"height": 1539081,
},
"headers": s15_headers,
"rows": s15_rows,
"compare_month": compare_month.ym,
},
{
"type": "slide15_top5_tables",
"slide": 15,
"shape_id": 7,
"shape_name": "Picture 1",
"target_box": {
"left": 5675312,
"top": 3716208,
"width": 8753475,
"height": 5372100,
},
"bags_rows": attach_product_images(bags_top5, vip_workdir),
"shoes_rows": attach_product_images(shoes_top5, vip_workdir),
},
]
)
operations = {
"replace_text": [],
"replace_tables": [],
"replace_charts": [],
"replace_images": [],
"replace_native_charts": native_ops,
}
ops_path.write_text(json.dumps(operations, ensure_ascii=False, indent=2), encoding="utf-8")
return {"operations_path": str(ops_path)}
def main() -> None:
args = parse_args()
config = load_runtime_config(args, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
result = execute(
config,
report_month=args.report_month,
report_year=args.report_year,
output_ops=args.output_ops,
slides=args.slides,
)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Any
import pymysql
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from runtime_config import add_config_source_args, load_runtime_config
CHANNELS = ["Target-Max", "\u552f\u667a\u5c55", "\u552f\u76f4\u8fbe", "\u552f\u89e6\u70b9", "\u817e\u8baf\u5e7f\u544a"]
OPTIONAL_TREND_CHANNELS = ["\u552f\u4eab\u5ba2"]
MONTH_ABBR = {
1: "Jan",
2: "Feb",
3: "Mar",
4: "Apr",
5: "May",
6: "Jun",
7: "Jul",
8: "Aug",
9: "Sep",
10: "Oct",
11: "Nov",
12: "Dec",
}
@dataclass(frozen=True)
class MonthKey:
year: int
month: int
@property
def ym(self) -> str:
return f"{self.year}-{self.month:02d}"
@property
def label(self) -> str:
return MONTH_ABBR[self.month]
def parse_month(raw: Any) -> int:
text = str(raw or "1").strip()
digits = "".join(ch for ch in text if ch.isdigit())
if not digits:
return 1
return min(12, max(1, int(digits)))
def to_decimal(value: Any) -> Decimal:
if value is None:
return Decimal("0")
text = str(value).replace(",", "").strip()
if not text:
return Decimal("0")
try:
return Decimal(text)
except Exception:
return Decimal("0")
def fmt_int(value: Decimal) -> str:
return f"{int(value.quantize(Decimal('1'), rounding=ROUND_HALF_UP)):,}"
def fmt_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP):,}"
def fmt_roi(return_value: Decimal, investment: Decimal) -> str:
if investment <= 0:
return "0.0"
return f"{(return_value / investment).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP):,}"
def fmt_pct(part: Decimal, total: Decimal, digits: int = 2) -> str:
if total <= 0:
return "-"
q = Decimal("1") if digits == 0 else Decimal("1." + "0" * digits)
return f"{((part / total) * Decimal('100')).quantize(q, rounding=ROUND_HALF_UP)}%"
def connect_from_config(cfg: dict[str, Any]):
mysql_cfg = cfg.get("mysql") or {}
return pymysql.connect(
host=mysql_cfg.get("host", "127.0.0.1"),
port=int(mysql_cfg.get("port", 3306)),
user=mysql_cfg.get("username") or mysql_cfg.get("user") or "root",
password=mysql_cfg.get("password") or "",
database=mysql_cfg.get("database") or "ckc_cep_db_test",
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
def table_exists(conn, table: str) -> bool:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS c
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s
""",
(table,),
)
return int((cur.fetchone() or {}).get("c") or 0) > 0
def table_count(conn, table: str) -> int:
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS c FROM `{table}`")
return int((cur.fetchone() or {}).get("c") or 0)
def fetch_columns(conn, table: str) -> set[str]:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s
""",
(table,),
)
return {str(r["COLUMN_NAME"]) for r in cur.fetchall()}
def fetch_attribution(conn, month: MonthKey) -> dict[str, dict[str, Decimal]]:
rows_by_channel: dict[str, dict[str, Decimal]] = {}
with conn.cursor() as cur:
cur.execute(
"""
SELECT
primary_entry,
product_detail_uv,
sales_amount,
customer_count,
marketing_new_customer_count,
add_to_cart_users
FROM cep_traffic_summary
WHERE daily = %s
AND primary_entry IN %s
AND (secondary_entry IS NULL OR secondary_entry = 'all')
ORDER BY CASE WHEN secondary_entry IS NULL THEN 0 ELSE 1 END
""",
(month.ym, CHANNELS),
)
for row in cur.fetchall():
name = str(row["primary_entry"])
if name in rows_by_channel:
continue
rows_by_channel[name] = {
"product_detail_uv": to_decimal(row.get("product_detail_uv")),
"sales_amount": to_decimal(row.get("sales_amount")),
"customer_count": to_decimal(row.get("customer_count")),
"marketing_new_customer_count": to_decimal(row.get("marketing_new_customer_count")),
"add_to_cart_users": to_decimal(row.get("add_to_cart_users")),
}
return rows_by_channel
def validate_otd_source(conn, table: str, month: MonthKey) -> tuple[bool, list[str], dict[str, Any]]:
diagnostics: dict[str, Any] = {
"table": table,
"exists": False,
"row_count": 0,
"report_month_rows": None,
"min_date": None,
"max_date": None,
"columns": [],
}
required_groups = {
"channel": {"channel", "media", "platform", "traffic_channel", "name", "\u6e20\u9053"},
"month": {"month", "daily", "date", "biz_date", "\u6708\u4efd", "\u65e5\u671f"},
"investment": {"investment", "spend", "cost", "fee", "consume", "platform_paid_traffic", "\u82b1\u8d39", "\u8d39\u7528", "\u6d88\u8017"},
"impressions": {"impressions", "impression", "exposure", "show", "display", "\u66dd\u5149", "\u66dd\u5149\u91cf", "\u5c55\u73b0"},
"clicks": {"clicks", "click", "click_qty", "\u70b9\u51fb", "\u70b9\u51fb\u91cf"},
}
if not table_exists(conn, table):
return False, [f"OTD source table `{table}` does not exist."], diagnostics
diagnostics["exists"] = True
count = table_count(conn, table)
diagnostics["row_count"] = count
cols = fetch_columns(conn, table)
diagnostics["columns"] = sorted(cols)
if count <= 0:
return False, [f"OTD source table `{table}` is empty."], diagnostics
lower_cols = {c.lower(): c for c in cols}
date_col = None
for candidate in ["date", "daily", "biz_date", "month", "\u65e5\u671f", "\u6708\u4efd"]:
if candidate.lower() in lower_cols:
date_col = lower_cols[candidate.lower()]
break
if date_col:
start = f"{month.year}-{month.month:02d}-01"
if month.month == 12:
end = f"{month.year + 1}-01-01"
else:
end = f"{month.year}-{month.month + 1:02d}-01"
with conn.cursor() as cur:
cur.execute(
f"""
SELECT
MIN(`{date_col}`) AS min_date,
MAX(`{date_col}`) AS max_date,
SUM(CASE WHEN `{date_col}` >= %s AND `{date_col}` < %s THEN 1 ELSE 0 END) AS report_month_rows
FROM `{table}`
""",
(start, end),
)
row = cur.fetchone() or {}
diagnostics["min_date"] = str(row.get("min_date")) if row.get("min_date") is not None else None
diagnostics["max_date"] = str(row.get("max_date")) if row.get("max_date") is not None else None
diagnostics["report_month_rows"] = int(row.get("report_month_rows") or 0)
errors: list[str] = []
if diagnostics["report_month_rows"] == 0:
errors.append(f"OTD source table `{table}` has no rows for report month {month.ym}.")
missing: list[str] = []
for group, aliases in required_groups.items():
if not any(alias.lower() in lower_cols for alias in aliases):
missing.append(group)
if missing:
errors.append(f"OTD source table `{table}` is missing required field group(s): {', '.join(missing)}.")
if errors:
return False, errors, diagnostics
return True, [], diagnostics
def build_diagnostic(output_dir: Path, month: MonthKey, otd_diag: dict[str, Any], errors: list[str], attribution: dict[str, dict[str, Decimal]]) -> Path:
data_dir = output_dir / "data" / "slide16-otd"
data_dir.mkdir(parents=True, exist_ok=True)
payload = {
"report_month": month.ym,
"status": "blocked",
"errors": errors,
"otd_source": otd_diag,
"available_attribution_rows": {
channel: {k: float(v) for k, v in values.items()} for channel, values in attribution.items()
},
"required_slide16_fields": [
"channel",
"investment",
"impressions",
"clicks",
"product_detail_uv",
"ctr",
"cpc",
"14_day_add_to_cart",
"14_day_customers",
"14_day_new_customers",
"14_day_new_customer_rate",
"14_day_sales",
"14_day_roi",
],
}
path = data_dir / "slide16-otd.diagnostic.json"
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build editable slide 16 OTD operations.")
add_config_source_args(parser, default_config=Path("config.yaml"))
parser.set_defaults(config_source="yaml")
parser.add_argument("--otd-table", default="ec_mkt_cost")
parser.add_argument("--output", default=r"output\vip-report\render-ops.slide16-otd.json")
parser.add_argument("--allow-incomplete", action="store_true", help="Only write diagnostics; do not fail when OTD spend data is missing.")
return parser.parse_args()
def main() -> None:
args = parse_args()
cfg = load_runtime_config(args, default_config=Path("config.yaml"))
report = cfg.get("report") or {}
month = MonthKey(int(report.get("year") or 2026), parse_month(report.get("month_cn")))
workdir = Path((cfg.get("paths") or {}).get("workdir") or r"output\vip-report")
if not workdir.is_absolute():
workdir = Path.cwd() / workdir
conn = connect_from_config(cfg)
try:
attribution = fetch_attribution(conn, month)
ok, errors, diag = validate_otd_source(conn, args.otd_table, month)
finally:
conn.close()
if not ok:
diag_path = build_diagnostic(workdir, month, diag, errors, attribution)
message = f"Slide 16 OTD source is incomplete. Diagnostic written to {diag_path}"
if args.allow_incomplete:
print(message)
return
raise SystemExit(message)
raise SystemExit(
"OTD source table exists, but this script has not mapped the source columns yet. "
"Add the column mapping once the source schema is confirmed."
)
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import base64
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
import yaml
NPX_EXECUTABLE = shutil.which("npx.cmd") or shutil.which("npx") or "npx"
PLAYWRIGHT_CMD = [NPX_EXECUTABLE, "--yes", "--package", "@playwright/cli", "playwright-cli"]
SESSION_NAME = "vip-report-monthly-sales"
DOWNLOAD_CENTER_URL = "https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter"
def run_cmd(args: list[str], *, cwd: Path, timeout: int = 300) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
npm_cache = cwd / ".npm-cache"
npm_cache.mkdir(parents=True, exist_ok=True)
env["NPM_CONFIG_CACHE"] = str(npm_cache)
env["npm_config_cache"] = str(npm_cache)
return subprocess.run(
args,
cwd=str(cwd),
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
timeout=timeout,
check=True,
env=env,
)
def build_js(task_id: str) -> str:
payload = json.dumps({"task_id": task_id, "url": DOWNLOAD_CENTER_URL}, ensure_ascii=False)
return f"""async function(page) {{
const spec = {payload};
await page.goto(spec.url, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
await page.waitForTimeout(1000);
const result = await page.evaluate(async (taskId) => {{
const body = new URLSearchParams();
body.set('reqData', JSON.stringify({{ taskId }}));
const res = await fetch('/report/asyncdownload/getOutputFile', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},
body: body.toString(),
credentials: 'include',
}});
const ab = await res.arrayBuffer();
const bytes = new Uint8Array(ab);
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {{
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}}
return {{
ok: res.ok,
status: res.status,
contentType: res.headers.get('content-type') || '',
disposition: res.headers.get('content-disposition') || '',
base64: btoa(binary),
}};
}}, String(spec.task_id));
return result;
}}
"""
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Download a ready Compass async task file by task id.")
p.add_argument("--config", default=r"D:\projects\skills\vip-report\config.yaml")
p.add_argument("--session", default=SESSION_NAME)
p.add_argument("--task-id", required=True)
p.add_argument("--output", required=True)
return p.parse_args()
def main() -> None:
args = parse_args()
cfg = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
workdir = Path(cfg["paths"]["workdir"]).resolve()
js_path = workdir / f"tmp-download-task-{args.task_id}-{time.time_ns()}.js"
js_path.write_text(build_js(args.task_id), encoding="utf-8")
try:
out = run_cmd(
PLAYWRIGHT_CMD + ["--session", args.session, "run-code", "--filename", str(js_path)],
cwd=workdir,
timeout=420,
).stdout
finally:
js_path.unlink(missing_ok=True)
if "### Error" in out:
raise RuntimeError(out.strip())
json_text = ""
marker = "### Result"
marker_idx = out.find(marker)
if marker_idx >= 0:
start = out.find("\n", marker_idx)
if start >= 0:
end = out.find("\n###", start + 1)
json_text = out[start + 1 : end if end >= 0 else None].strip()
if not json_text:
for line in out.splitlines():
s = line.strip()
if s.startswith("{") and s.endswith("}"):
json_text = s
break
if not json_text:
raise RuntimeError(f"No JSON result from run-code: {out}")
result = json.loads(json_text)
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
output_path = Path(args.output).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(base64.b64decode(result["base64"]))
result.pop("base64", None)
result["savedAs"] = str(output_path)
print(json.dumps(result, ensure_ascii=True))
if __name__ == "__main__":
main()
......@@ -170,8 +170,9 @@ def main() -> None:
raise RuntimeError(f"Missing required columns: {missing}")
time_values = {str(x).strip() for x in df["时间"].dropna().head(20)}
if not any("202603" in x for x in time_values):
raise RuntimeError(f"Unexpected time values for 2026-03 file: {sorted(time_values)[:5]}")
expected_month = data_month.replace("-", "")
if not any(expected_month in x for x in time_values):
raise RuntimeError(f"Unexpected time values for {data_month} file: {sorted(time_values)[:5]}")
rows = parse_rows(df, data_month, source_file.name)
mysql_cfg = cfg["mysql"]
......
......@@ -6,12 +6,9 @@
$ErrorActionPreference = "Stop"
# 鍏抽敭琛屾敞閲婏細杩欓噷鏄叧閿鐞嗚妭鐐广€?
# 鍑芥暟璇存槑锛氬皝瑁匞et-ShapeChildren鏂规硶鐨勬牳蹇冨鐞嗘祦绋嬨€?
function Get-ShapeChildren {
param([Parameter(Mandatory = $true)]$Shape)
$children = @()
# 鍏抽敭琛屾敞閲婏細杩涘叆鏍稿績鎵ц鍖猴紝骞跺湪 finally 涓噴鏀?PowerPoint 璧勬簮銆?
try {
$count = $Shape.GroupItems.Count
for ($i = 1; $i -le $count; $i++) {
......@@ -19,13 +16,12 @@ function Get-ShapeChildren {
$children += $child
$children += Get-ShapeChildren -Shape $child
}
} catch {
}
catch {
}
return $children
}
# 鍏抽敭琛屾敞閲婏細杩欓噷鏄叧閿鐞嗚妭鐐广€?
# 鍑芥暟璇存槑锛氬皝瑁匜ind-Shape鏂规硶鐨勬牳蹇冨鐞嗘祦绋嬨€?
function Find-Shape {
param(
[Parameter(Mandatory = $true)]$Slide,
......@@ -62,7 +58,7 @@ function Find-Shape {
$shapeNumMatch = [regex]::Match([string]$shape.Name, "(\d+)\s*$")
if ($shapeNumMatch.Success -and $shapeNumMatch.Groups[1].Value -eq $targetNumber) {
$shapeNameLower = ([string]$shape.Name).ToLowerInvariant()
if ($shapeNameLower.Contains("picture")) {
if ($shapeNameLower.Contains("picture") -or $shapeNameLower.Contains("group") -or $shapeNameLower.Contains("table")) {
return $shape
}
}
......@@ -70,12 +66,12 @@ function Find-Shape {
}
$text = $null
# 鍏抽敭琛屾敞閲婏細杩涘叆鏍稿績鎵ц鍖猴紝骞跺湪 finally 涓噴鏀?PowerPoint 璧勬簮銆?
try {
if ($shape.HasTextFrame -eq -1 -and $shape.TextFrame.HasText -eq -1) {
$text = $shape.TextFrame.TextRange.Text
}
} catch {
}
catch {
$text = $null
}
......@@ -91,8 +87,6 @@ function Find-Shape {
return $null
}
# 鍏抽敭琛屾敞閲婏細杩欓噷鏄叧閿鐞嗚妭鐐广€?
# 鍑芥暟璇存槑锛氬皝瑁匰et-ShapeText鏂规硶鐨勬牳蹇冨鐞嗘祦绋嬨€?
function Set-ShapeText {
param(
[Parameter(Mandatory = $true)]$Shape,
......@@ -102,12 +96,30 @@ function Set-ShapeText {
if ($Shape.HasTextFrame -ne -1) {
throw "Shape '$($Shape.Name)' does not support text."
}
$Shape.TextFrame.TextRange.Text = $NewText
}
# 鍏抽敭琛屾敞閲婏細杩欓噷鏄叧閿鐞嗚妭鐐广€?
# 鍑芥暟璇存槑锛氬皝瑁匯eplace-PictureShape鏂规硶鐨勬牳蹇冨鐞嗘祦绋嬨€?
function Set-ShapeZOrderAndName {
param(
[Parameter(Mandatory = $true)]$Shape,
[int]$TargetZ = 0,
[string]$Name = ""
)
if ($TargetZ -gt 0) {
while ($Shape.ZOrderPosition -lt $TargetZ) {
$Shape.ZOrder(1) | Out-Null
}
}
if ($Name) {
try {
$Shape.Name = $Name
}
catch {
}
}
}
function Replace-PictureShape {
param(
[Parameter(Mandatory = $true)]$Slide,
......@@ -128,13 +140,607 @@ function Replace-PictureShape {
$Shape.Delete()
$newShape = $Slide.Shapes.AddPicture($ImagePath, $false, $true, $left, $top, $width, $height)
while ($newShape.ZOrderPosition -lt $z) {
$newShape.ZOrder(1) | Out-Null
Set-ShapeZOrderAndName -Shape $newShape -TargetZ $z -Name $name
}
function Add-PictureAtBox {
param(
[Parameter(Mandatory = $true)]$Slide,
[Parameter(Mandatory = $true)]$Item
)
$imagePath = [string]$Item.image_path
if (-not (Test-Path -LiteralPath $imagePath)) {
throw "ImagePath not found: $imagePath"
}
$box = $Item.target_box
if ($null -eq $box) {
throw "target_box is required when image op has no target shape."
}
$left = [double]$box.left / 12700.0
$top = [double]$box.top / 12700.0
$width = [double]$box.width / 12700.0
$height = [double]$box.height / 12700.0
$newShape = $Slide.Shapes.AddPicture($imagePath, $false, $true, $left, $top, $width, $height)
if ($Item.shape_name) {
try {
$newShape.Name = [string]$Item.shape_name
}
catch {
}
}
}
function Try-UnblockFileSafe {
param([string]$Path)
if (-not $Path) {
return
}
if (-not (Test-Path -LiteralPath $Path)) {
return
}
try {
Unblock-File -Path $Path -ErrorAction SilentlyContinue
}
catch {
}
}
function New-PowerPointAppWithRetry {
param(
[int]$MaxAttempts = 3,
[int]$DelayMs = 1000
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$app = $null
try {
$app = New-Object -ComObject PowerPoint.Application
try { $app.DisplayAlerts = 1 } catch {}
try { $app.Visible = -1 } catch {}
$presentations = $app.Presentations
if ($null -ne $presentations) {
return $app
}
throw "PowerPoint Presentations collection is null."
}
catch {
$lastError = $_
if ($app -ne $null) {
try { $app.Quit() | Out-Null } catch {}
}
Start-Sleep -Milliseconds $DelayMs
}
}
if ($lastError) {
throw $lastError
}
throw "Unable to initialize PowerPoint COM. Ensure desktop Office is installed and the session can create PowerPoint.Application."
}
function Try-OpenPresentation {
param(
[Parameter(Mandatory = $true)]$PptApp,
[Parameter(Mandatory = $true)][string]$Path,
[int]$MaxAttempts = 3,
[int]$DelayMs = 1200
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
Try-UnblockFileSafe -Path $Path
$presentations = $PptApp.Presentations
if ($null -eq $presentations) {
throw "PowerPoint Presentations collection is null."
}
$pres = $presentations.Open($Path, 0, 0, 0)
if ($null -ne $pres) {
return $pres
}
}
catch {
$lastError = $_
Start-Sleep -Milliseconds $DelayMs
}
}
if ($lastError) {
throw $lastError
}
throw "PowerPoint failed to open file: $Path"
}
function Open-PresentationWithFallback {
param(
[Parameter(Mandatory = $true)]$PptApp,
[Parameter(Mandatory = $true)][string]$OutputPath,
[Parameter(Mandatory = $true)][string]$TemplatePath
)
# 1) Primary: open output directly.
try {
$pres = Try-OpenPresentation -PptApp $PptApp -Path $OutputPath
return [pscustomobject]@{
Presentation = $pres
OpenPath = $OutputPath
UsingTempCopy = $false
TempPath = ""
}
}
catch {
}
# 2) Secondary: open template directly.
if (
$TemplatePath -and
(Test-Path -LiteralPath $TemplatePath) -and
(-not $TemplatePath.Equals($OutputPath, [System.StringComparison]::OrdinalIgnoreCase))
) {
try {
$pres = Try-OpenPresentation -PptApp $PptApp -Path $TemplatePath
return [pscustomobject]@{
Presentation = $pres
OpenPath = $TemplatePath
UsingTempCopy = $false
TempPath = ""
}
}
catch {
}
}
# 3) Last resort: copy to temp and open temp copy.
$sourcePath = $OutputPath
if (-not (Test-Path -LiteralPath $sourcePath)) {
$sourcePath = $TemplatePath
}
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Neither output nor template exists for fallback open. output=$OutputPath template=$TemplatePath"
}
$tempDir = Join-Path $env:TEMP "vip-report-render"
if (-not (Test-Path -LiteralPath $tempDir)) {
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
}
$tempPath = Join-Path $tempDir ("render-work-" + [guid]::NewGuid().ToString("N") + ".pptx")
Copy-Item -LiteralPath $sourcePath -Destination $tempPath -Force
Try-UnblockFileSafe -Path $tempPath
$tempPres = Try-OpenPresentation -PptApp $PptApp -Path $tempPath
return [pscustomobject]@{
Presentation = $tempPres
OpenPath = $tempPath
UsingTempCopy = $true
TempPath = $tempPath
}
}
function Convert-HexToRgbInt {
param([string]$Hex)
$clean = ([string]$Hex).Trim()
if ($clean.StartsWith("#")) {
$clean = $clean.Substring(1)
}
if ($clean.Length -ne 6) {
return 0
}
$r = [Convert]::ToInt32($clean.Substring(0, 2), 16)
$g = [Convert]::ToInt32($clean.Substring(2, 2), 16)
$b = [Convert]::ToInt32($clean.Substring(4, 2), 16)
return ($r + ($g * 256) + ($b * 65536))
}
function Set-ChartData {
param(
[Parameter(Mandatory = $true)]$Chart,
[Parameter(Mandatory = $true)][string[]]$Headers,
[Parameter(Mandatory = $true)][object[][]]$Rows
)
$Chart.ChartData.Activate()
$wb = $Chart.ChartData.Workbook
$ws = $wb.Worksheets.Item(1)
try {
$cols = $Headers.Count
for ($c = 1; $c -le $cols; $c++) {
$ws.Cells.Item(1, $c).Value2 = $Headers[$c - 1]
}
for ($r = 1; $r -le $Rows.Count; $r++) {
$row = $Rows[$r - 1]
for ($c = 1; $c -le $cols; $c++) {
$value = $row[$c - 1]
$ws.Cells.Item($r + 1, $c).Value2 = $value
}
}
$endRow = $Rows.Count + 1
$range = $ws.Range("A1", [string]([char](64 + $Headers.Count)) + $endRow)
$Chart.SetSourceData($range)
}
finally {
try {
$wb.Application.Quit()
}
catch {
}
}
}
function Set-ComRgb {
param(
$Target,
[Parameter(Mandatory = $true)][int]$Rgb
)
if ($null -eq $Target) {
return
}
# 鍏抽敭琛屾敞閲婏細杩涘叆鏍稿績鎵ц鍖猴紝骞跺湪 finally 涓噴鏀?PowerPoint 璧勬簮銆?
try {
$newShape.Name = $name
} catch {
$Target.RGB = $Rgb
return
}
catch {
}
try {
$Target.ForeColor.RGB = $Rgb
}
catch {
}
}
function Add-Slide12TrafficTable {
param(
[Parameter(Mandatory = $true)]$Slide,
[Parameter(Mandatory = $true)]$OldShape,
[Parameter(Mandatory = $true)]$Item
)
$left = $OldShape.Left
$top = $OldShape.Top
$width = $OldShape.Width
$height = $OldShape.Height
$z = $OldShape.ZOrderPosition
$name = $OldShape.Name
$OldShape.Delete()
$headers = @($Item.headers)
$rows = @($Item.rows)
$rowCount = $rows.Count + 1
$colCount = $headers.Count
$tableShape = $Slide.Shapes.AddTable($rowCount, $colCount, $left, $top, $width, $height)
Set-ShapeZOrderAndName -Shape $tableShape -TargetZ $z -Name $name
$table = $tableShape.Table
$baseWidths = @(92, 128, 144, 110, 144, 110, 122, 144, 110, 144, 110)
if ($colCount -eq $baseWidths.Count) {
$baseTotal = 0
foreach ($bw in $baseWidths) { $baseTotal += $bw }
for ($i = 1; $i -le $colCount; $i++) {
$table.Columns.Item($i).Width = $width * ($baseWidths[$i - 1] / $baseTotal)
}
}
$headerBg = Convert-HexToRgbInt "#5f6fce"
$gridColor = Convert-HexToRgbInt "#d5d5d5"
$black = Convert-HexToRgbInt "#111111"
$white = Convert-HexToRgbInt "#ffffff"
$lflPos = Convert-HexToRgbInt "#d11f1f"
$lflNeg = Convert-HexToRgbInt "#0f9d58"
for ($c = 1; $c -le $colCount; $c++) {
$cell = $table.Cell(1, $c).Shape
$cell.TextFrame.TextRange.Text = [string]$headers[$c - 1]
Set-ComRgb -Target $cell.Fill.ForeColor -Rgb $headerBg
Set-ComRgb -Target $cell.Line.ForeColor -Rgb $gridColor
Set-ComRgb -Target $cell.TextFrame.TextRange.Font.Color -Rgb $white
$cell.TextFrame.TextRange.Font.Bold = $true
$cell.TextFrame.TextRange.Font.Size = 10
$cell.TextFrame.TextRange.ParagraphFormat.Alignment = 2
$cell.TextFrame.VerticalAnchor = 3
$cell.TextFrame.MarginLeft = 2
$cell.TextFrame.MarginRight = 2
$cell.TextFrame.MarginTop = 1
$cell.TextFrame.MarginBottom = 1
}
for ($r = 1; $r -le $rows.Count; $r++) {
$row = @($rows[$r - 1])
for ($c = 1; $c -le $colCount; $c++) {
$value = [string]$row[$c - 1]
$cell = $table.Cell($r + 1, $c).Shape
$cell.TextFrame.TextRange.Text = $value
Set-ComRgb -Target $cell.Fill.ForeColor -Rgb $white
Set-ComRgb -Target $cell.Line.ForeColor -Rgb $gridColor
$cell.TextFrame.TextRange.Font.Bold = ($r -eq $rows.Count)
$cell.TextFrame.TextRange.Font.Size = 10
$cell.TextFrame.TextRange.ParagraphFormat.Alignment = 2
$cell.TextFrame.VerticalAnchor = 3
$cell.TextFrame.MarginLeft = 2
$cell.TextFrame.MarginRight = 2
$cell.TextFrame.MarginTop = 1
$cell.TextFrame.MarginBottom = 1
$fontColor = $black
if ($r -eq $rows.Count -and $c -gt 1) {
if ($value.StartsWith("-")) {
$fontColor = $lflNeg
}
elseif (-not $value.StartsWith("0")) {
$fontColor = $lflPos
}
}
Set-ComRgb -Target $cell.TextFrame.TextRange.Font.Color -Rgb $fontColor
}
}
}
function Add-Slide12TrendPanel {
param(
[Parameter(Mandatory = $true)]$Slide,
[double]$Left,
[double]$Top,
[double]$Width,
[double]$Height,
[int]$Month,
[Parameter(Mandatory = $true)]$Panel
)
$shape = $Slide.Shapes.AddChart2(201, 4, $Left, $Top, $Width, $Height)
$chart = $shape.Chart
$chart.HasTitle = $true
$chart.ChartTitle.Text = [string]$Panel.title
$chart.ChartTitle.Format.TextFrame2.TextRange.Font.Name = "Arial"
$chart.ChartTitle.Format.TextFrame2.TextRange.Font.Size = 14
$chart.HasLegend = $true
$chart.Legend.Position = -4160
$chart.PlotArea.Format.Fill.ForeColor.RGB = Convert-HexToRgbInt "#e7e7e7"
$chart.ChartArea.Format.Fill.ForeColor.RGB = Convert-HexToRgbInt "#e7e7e7"
$currentValues = @($Panel.current_values)
$previousValues = @($Panel.previous_values)
$lflValues = @($Panel.lfl_values)
$count = $currentValues.Count
$rows = @()
for ($i = 0; $i -lt $count; $i++) {
$day = $i + 1
$label = ""
if ($day -eq 1 -or $day -eq 8 -or $day -eq 16 -or $day -eq 23 -or $day -eq $count) {
$label = "$($Month)-$($day)"
}
$rows += ,@($label, [double]$currentValues[$i], [double]$previousValues[$i], [double]$lflValues[$i])
}
Set-ChartData -Chart $chart -Headers @("Day", [string]$Panel.current_name, [string]$Panel.previous_name, "LFL") -Rows $rows
$series1 = $chart.SeriesCollection(1)
$series2 = $chart.SeriesCollection(2)
$series3 = $chart.SeriesCollection(3)
$series1.ChartType = 4
$series2.ChartType = 4
$series3.ChartType = 65
$series3.AxisGroup = 2
$colorCurrent = Convert-HexToRgbInt "#6f76ff"
$colorPrevious = Convert-HexToRgbInt "#f08ad9"
$colorLfl = Convert-HexToRgbInt "#b000c9"
$series1.Format.Line.ForeColor.RGB = $colorCurrent
$series2.Format.Line.ForeColor.RGB = $colorPrevious
$series3.Format.Line.Visible = 0
$series3.MarkerStyle = 8
$series3.MarkerSize = 6
$series3.MarkerForegroundColor = $colorLfl
$series3.MarkerBackgroundColor = $colorLfl
$valueAxis = $chart.Axes(2, 1)
$valueAxis.MinimumScale = 0
$valueAxis.MaximumScale = [double]$Panel.value_axis_max
$valueAxis.MajorUnit = [double]$Panel.value_axis_major_unit
$lflAxis = $chart.Axes(2, 2)
$lflAxis.MinimumScale = [double]$Panel.lfl_axis_min
$lflAxis.MaximumScale = [double]$Panel.lfl_axis_max
$lflAxis.MajorUnit = [double]$Panel.lfl_axis_major_unit
$lflAxis.TickLabels.NumberFormat = "0%"
$categoryAxis = $chart.Axes(1, 1)
$categoryAxis.TickLabelSpacing = 1
$categoryAxis.TickMarkSpacing = 1
$categoryAxis.HasTitle = $false
return $shape
}
function Replace-Slide12DualLineLfl {
param(
[Parameter(Mandatory = $true)]$Slide,
[Parameter(Mandatory = $true)]$OldShape,
[Parameter(Mandatory = $true)]$Item
)
$left = $OldShape.Left
$top = $OldShape.Top
$width = $OldShape.Width
$height = $OldShape.Height
$z = $OldShape.ZOrderPosition
$name = $OldShape.Name
$OldShape.Delete()
$panels = @($Item.panels)
if ($panels.Count -lt 2) {
throw "slide12_dual_line_lfl requires 2 panel configs."
}
$gap = 12.0
$panelWidth = ($width - $gap) / 2.0
$panelHeight = $height
$month = [int]$Item.month
$leftChartShape = Add-Slide12TrendPanel -Slide $Slide -Left $left -Top $top -Width $panelWidth -Height $panelHeight -Month $month -Panel $panels[0]
$rightChartShape = Add-Slide12TrendPanel -Slide $Slide -Left ($left + $panelWidth + $gap) -Top $top -Width $panelWidth -Height $panelHeight -Month $month -Panel $panels[1]
Set-ShapeZOrderAndName -Shape $leftChartShape -TargetZ $z -Name "$name-L"
Set-ShapeZOrderAndName -Shape $rightChartShape -TargetZ ($z + 1) -Name "$name-R"
}
function Get-EntryDonutData {
param([Parameter(Mandatory = $true)]$EntryRows, [Parameter(Mandatory = $true)][string]$Field)
$pairs = @()
foreach ($row in $EntryRows) {
$pairs += ,@([string]$row.name, [double]$row.$Field)
}
$sorted = $pairs | Sort-Object { $_[1] } -Descending
$top = @()
$other = 0.0
for ($i = 0; $i -lt $sorted.Count; $i++) {
if ($i -lt 5) {
$top += ,$sorted[$i]
}
else {
$other += [double]$sorted[$i][1]
}
}
if ($other -gt 0) {
$top += ,@("Other", $other)
}
return $top
}
function Add-SimpleChartFromRows {
param(
[Parameter(Mandatory = $true)]$Slide,
[int]$ChartType,
[double]$Left,
[double]$Top,
[double]$Width,
[double]$Height,
[Parameter(Mandatory = $true)][string[]]$Headers,
[Parameter(Mandatory = $true)][object[][]]$Rows,
[string]$Title = ""
)
$shape = $Slide.Shapes.AddChart2(201, $ChartType, $Left, $Top, $Width, $Height)
$chart = $shape.Chart
if ($Title) {
$chart.HasTitle = $true
$chart.ChartTitle.Text = $Title
$chart.ChartTitle.Format.TextFrame2.TextRange.Font.Name = "Arial"
$chart.ChartTitle.Format.TextFrame2.TextRange.Font.Size = 14
}
else {
$chart.HasTitle = $false
}
Set-ChartData -Chart $chart -Headers $Headers -Rows $Rows
return $shape
}
function Replace-Slide12EntryMix {
param(
[Parameter(Mandatory = $true)]$Slide,
[Parameter(Mandatory = $true)]$OldShape,
[Parameter(Mandatory = $true)]$Item
)
$left = $OldShape.Left
$top = $OldShape.Top
$width = $OldShape.Width
$height = $OldShape.Height
$z = $OldShape.ZOrderPosition
$name = $OldShape.Name
$OldShape.Delete()
$entries = @($Item.entries)
$reportYear = [int]$Item.report_year
$compareYear = [int]$Item.compare_year
$reportLabel = ("{0}UV" -f ($reportYear.ToString().Substring($reportYear.ToString().Length - 2)))
$compareLabel = ("{0}UV" -f ($compareYear.ToString().Substring($compareYear.ToString().Length - 2)))
$leftWidth = [double]($width * 0.30)
$rightWidth = $width - $leftWidth
$donutGap = 10.0
$donutWidth = ($rightWidth - $donutGap) / 2.0
$barRows = @()
foreach ($row in $entries) {
$barRows += ,@([string]$row.name, [double]$row.report_uv, [double]$row.compare_uv)
}
$barShape = Add-SimpleChartFromRows -Slide $Slide -ChartType 51 -Left $left -Top $top -Width $leftWidth -Height $height -Headers @("Entry", $reportLabel, $compareLabel) -Rows $barRows -Title "Entry Traffic LFL"
$barChart = $barShape.Chart
$barChart.HasLegend = $true
$barChart.Legend.Position = -4160
$barChart.SeriesCollection(1).Format.Fill.ForeColor.RGB = Convert-HexToRgbInt "#6f76ff"
$barChart.SeriesCollection(2).Format.Fill.ForeColor.RGB = Convert-HexToRgbInt "#f08ad9"
$barChart.Axes(1, 1).TickLabels.Orientation = 35
$reportDonutRows = Get-EntryDonutData -EntryRows $entries -Field "report_uv"
$compareDonutRows = Get-EntryDonutData -EntryRows $entries -Field "compare_uv"
$donutHeaders = @("Entry", "UV")
$donut1Rows = @()
foreach ($row in $reportDonutRows) {
$donut1Rows += ,@($row[0], [double]$row[1])
}
$donut2Rows = @()
foreach ($row in $compareDonutRows) {
$donut2Rows += ,@($row[0], [double]$row[1])
}
$donutLeft = $left + $leftWidth
$donutTop = [Math]::Max(0, $top - ($height * 0.08))
$donutHeight = $height * 1.16
$donut1Shape = Add-SimpleChartFromRows -Slide $Slide -ChartType -4120 -Left $donutLeft -Top $donutTop -Width $donutWidth -Height $donutHeight -Headers $donutHeaders -Rows $donut1Rows -Title ("{0} Entry Mix" -f ($reportYear.ToString().Substring($reportYear.ToString().Length - 2)))
$donut2Shape = Add-SimpleChartFromRows -Slide $Slide -ChartType -4120 -Left ($donutLeft + $donutWidth + $donutGap) -Top $donutTop -Width $donutWidth -Height $donutHeight -Headers $donutHeaders -Rows $donut2Rows -Title ("{0} Entry Mix" -f ($compareYear.ToString().Substring($compareYear.ToString().Length - 2)))
foreach ($donutShape in @($donut1Shape, $donut2Shape)) {
$chart = $donutShape.Chart
$chart.HasLegend = $false
$series = $chart.SeriesCollection(1)
$series.HasDataLabels = $true
$series.DataLabels.ShowCategoryName = $true
$series.DataLabels.ShowPercentage = $true
$series.DataLabels.ShowValue = $false
try {
$series.DataLabels.Format.TextFrame2.TextRange.Font.Size = 10
$series.DataLabels.Format.TextFrame2.TextRange.Font.Bold = -1
} catch {}
try {
$series.DoughnutHoleSize = 65
}
catch {
}
}
Set-ShapeZOrderAndName -Shape $barShape -TargetZ $z -Name "$name-Bar"
Set-ShapeZOrderAndName -Shape $donut1Shape -TargetZ ($z + 1) -Name "$name-Donut-Report"
Set-ShapeZOrderAndName -Shape $donut2Shape -TargetZ ($z + 2) -Name "$name-Donut-Compare"
}
function Apply-NativeChartOperation {
param(
[Parameter(Mandatory = $true)]$Presentation,
[Parameter(Mandatory = $true)]$Item
)
$slide = $Presentation.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 "Native chart target not found on slide $($item.slide)"
}
$type = [string]$item.type
switch ($type) {
"slide12_traffic_table" {
Add-Slide12TrafficTable -Slide $slide -OldShape $shape -Item $item
break
}
"slide12_dual_line_lfl" {
Replace-Slide12DualLineLfl -Slide $slide -OldShape $shape -Item $item
break
}
"slide12_entry_mix" {
Replace-Slide12EntryMix -Slide $slide -OldShape $shape -Item $item
break
}
default {
throw "Unsupported native chart operation type: $type"
}
}
}
......@@ -151,6 +757,7 @@ $ops = [pscustomobject]@{
replace_tables = @()
replace_charts = @()
replace_images = @()
replace_native_charts = @()
}
if ($OperationsPath) {
......@@ -164,6 +771,7 @@ $replaceText = @()
$replaceTables = @()
$replaceCharts = @()
$replaceImages = @()
$replaceNativeCharts = @()
if ($ops.PSObject.Properties.Name -contains "replace_text") {
$replaceText = @($ops.replace_text)
}
......@@ -176,6 +784,9 @@ if ($ops.PSObject.Properties.Name -contains "replace_charts") {
if ($ops.PSObject.Properties.Name -contains "replace_images") {
$replaceImages = @($ops.replace_images)
}
if ($ops.PSObject.Properties.Name -contains "replace_native_charts") {
$replaceNativeCharts = @($ops.replace_native_charts)
}
$outDir = Split-Path -Parent $OutputPath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
......@@ -186,19 +797,24 @@ if (-not $isInPlaceUpdate) {
Copy-Item -LiteralPath $TemplatePath -Destination $OutputPath -Force
}
if ($replaceText.Count -eq 0 -and $replaceTables.Count -eq 0 -and $replaceCharts.Count -eq 0 -and $replaceImages.Count -eq 0) {
# 鏃犳浛鎹㈡搷浣滄椂鐩存帴澶嶅埗妯℃澘锛岄伩鍏?PowerPoint 閲嶆柊淇濆瓨閫犳垚鍖呯粨鏋勫樊寮傘€?
if (
$replaceText.Count -eq 0 -and
$replaceTables.Count -eq 0 -and
$replaceCharts.Count -eq 0 -and
$replaceImages.Count -eq 0 -and
$replaceNativeCharts.Count -eq 0
) {
Write-Output $OutputPath
return
}
$ppt = $null
$pres = $null
# 鍏抽敭琛屾敞閲婏細杩涘叆鏍稿績鎵ц鍖猴紝骞跺湪 finally 涓噴鏀?PowerPoint 璧勬簮銆?
$openResult = $null
try {
$ppt = New-Object -ComObject PowerPoint.Application
$pres = $ppt.Presentations.Open($OutputPath, 0, 0, 0)
$ppt = New-PowerPointAppWithRetry
$openResult = Open-PresentationWithFallback -PptApp $ppt -OutputPath $OutputPath -TemplatePath $TemplatePath
$pres = $openResult.Presentation
foreach ($item in $replaceText) {
$slide = $pres.Slides.Item([int]$item.slide)
......@@ -211,6 +827,10 @@ try {
foreach ($item in $replaceImages) {
$slide = $pres.Slides.Item([int]$item.slide)
if ((-not $item.shape_id) -and $item.target_box) {
Add-PictureAtBox -Slide $slide -Item $item
continue
}
$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)"
......@@ -227,7 +847,6 @@ try {
if ($shape.HasTable -ne -1) {
throw "Shape '$($shape.Name)' is not a table."
}
foreach ($cell in @($item.cells)) {
$row = [int]$cell.row
$col = [int]$cell.col
......@@ -250,7 +869,6 @@ try {
foreach ($series in @($item.series)) {
$index = [int]$series.index
$chartSeries = $chart.SeriesCollection($index)
if ($series.PSObject.Properties.Name -contains "name") {
try { $chartSeries.Name = [string]$series.name } catch {}
}
......@@ -264,14 +882,40 @@ try {
try { $chart.Refresh() | Out-Null } catch {}
}
$pres.Save()
foreach ($item in $replaceNativeCharts) {
Apply-NativeChartOperation -Presentation $pres -Item $item
}
if ($openResult -and $openResult.UsingTempCopy) {
if (Test-Path -LiteralPath $OutputPath) {
Remove-Item -LiteralPath $OutputPath -Force
}
# 24 = ppSaveAsOpenXMLPresentation (.pptx)
$pres.SaveAs($OutputPath, 24)
}
elseif ($openResult -and (-not $openResult.OpenPath.Equals($OutputPath, [System.StringComparison]::OrdinalIgnoreCase))) {
$pres.SaveAs($OutputPath, 24)
}
else {
$pres.Save()
}
Write-Output $OutputPath
} finally {
}
finally {
if ($pres -ne $null) {
try { $pres.Close() | Out-Null } catch {}
}
if ($ppt -ne $null) {
try { $ppt.Quit() | Out-Null } catch {}
}
if ($openResult -and $openResult.UsingTempCopy -and $openResult.TempPath) {
try {
if (Test-Path -LiteralPath $openResult.TempPath) {
Remove-Item -LiteralPath $openResult.TempPath -Force
}
}
catch {
}
}
}
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml import parse_xml
from pptx.oxml.ns import nsdecls
from pptx.util import Pt
C_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Fallback PPT renderer using python-pptx (no COM).")
p.add_argument("--template", required=True)
p.add_argument("--output", required=True)
p.add_argument("--operations", required=True)
return p.parse_args()
def find_shape(slide, shape_id: int | None, shape_name: str | None):
for shp in slide.shapes:
if shape_id and getattr(shp, "shape_id", None) == shape_id:
return shp
if shape_name:
for shp in slide.shapes:
if getattr(shp, "name", "") == shape_name:
return shp
return None
def apply_text_ops(prs: Presentation, ops: list[dict[str, Any]]) -> None:
for item in ops:
slide = prs.slides[int(item["slide"]) - 1]
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is None or not getattr(shape, "has_text_frame", False):
raise RuntimeError(f"Text target not found on slide {item.get('slide')}")
shape.text_frame.text = str(item.get("new_text", ""))
def get_target_box(item: dict[str, Any]) -> tuple[int, int, int, int]:
box = item.get("target_box") or {}
return (
int(box.get("left", 0)),
int(box.get("top", 0)),
int(box.get("width", 0)),
int(box.get("height", 0)),
)
def remove_shape(shape) -> None:
el = shape._element
el.getparent().remove(el)
def set_text_style(tf, *, size: int, bold: bool, rgb: RGBColor, align=PP_ALIGN.CENTER) -> None:
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
if not tf.paragraphs:
tf.add_paragraph()
p = tf.paragraphs[0]
p.alignment = align
if not p.runs:
p.add_run()
run = p.runs[0]
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = rgb
def _first(root, local_name: str):
matches = root.xpath(f'.//*[local-name()="{local_name}"]')
return matches[0] if matches else None
def _direct_children(el, local_name: str):
return [child for child in el if child.tag == f"{{{C_NS}}}{local_name}"]
def _set_child_val(el, local_name: str, value: str) -> None:
child = None
for candidate in el:
if candidate.tag == f"{{{C_NS}}}{local_name}":
child = candidate
break
if child is None:
child = parse_xml(f'<c:{local_name} {nsdecls("c")} val="{value}"/>')
el.append(child)
else:
child.set("val", value)
def _make_shape_props(hex_color: str, *, width: int = 19050, no_fill: bool = False) -> str:
fill = "<a:noFill/>" if no_fill else f'<a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill>'
return (
f'<c:spPr {nsdecls("c", "a")}>'
f'<a:ln w="{width}"><a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill></a:ln>'
f"{fill}"
"</c:spPr>"
)
def _make_marker_series_props(hex_color: str, *, line: bool) -> str:
line_xml = (
f'<a:ln w="25400"><a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill></a:ln>'
if line
else '<a:ln><a:noFill/></a:ln>'
)
return (
f'<c:spPr {nsdecls("c", "a")}>'
f"{line_xml}"
'<a:noFill/>'
'</c:spPr>'
)
def _insert_after(parent, after_el, new_el) -> None:
parent.insert(parent.index(after_el) + 1, new_el)
def _format_combo_axes(chart, *, primary_max: float, primary_unit: float, secondary_min: float, secondary_max: float, secondary_unit: float) -> None:
root = chart._element
val_axes = root.xpath('.//*[local-name()="valAx"]')
cat_axis = _first(root, "catAx")
primary_axis = val_axes[0] if val_axes else None
secondary_axis = val_axes[1] if len(val_axes) > 1 else None
if primary_axis is not None:
scaling = _direct_children(primary_axis, "scaling")[0] if _direct_children(primary_axis, "scaling") else None
if scaling is not None:
scaling.clear()
scaling.append(parse_xml(f'<c:orientation {nsdecls("c")} val="minMax"/>'))
scaling.append(parse_xml(f'<c:max {nsdecls("c")} val="{primary_max}"/>'))
scaling.append(parse_xml(f'<c:min {nsdecls("c")} val="0"/>'))
_set_child_val(primary_axis, "majorUnit", str(primary_unit))
if secondary_axis is not None:
scaling = _direct_children(secondary_axis, "scaling")[0] if _direct_children(secondary_axis, "scaling") else None
if scaling is not None:
scaling.clear()
scaling.append(parse_xml(f'<c:orientation {nsdecls("c")} val="minMax"/>'))
scaling.append(parse_xml(f'<c:max {nsdecls("c")} val="{secondary_max}"/>'))
scaling.append(parse_xml(f'<c:min {nsdecls("c")} val="{secondary_min}"/>'))
_set_child_val(secondary_axis, "majorUnit", str(secondary_unit))
_set_child_val(secondary_axis, "numFmt", "0%")
# numFmt uses a different attribute name, so fix it after helper insertion.
num_fmt = _direct_children(secondary_axis, "numFmt")[0] if _direct_children(secondary_axis, "numFmt") else None
if num_fmt is not None:
num_fmt.attrib.clear()
num_fmt.set("formatCode", "0%")
num_fmt.set("sourceLinked", "0")
if cat_axis is not None:
_set_child_val(cat_axis, "tickLblSkip", "1")
_set_child_val(cat_axis, "tickMarkSkip", "1")
def convert_last_series_to_secondary_line(chart, *, primary_max: float, primary_unit: float, secondary_min: float, secondary_max: float, secondary_unit: float, color: str = "B000C9", line: bool = False) -> None:
root = chart._element
plot_area = _first(root, "plotArea")
source_chart = _first(root, "barChart")
if source_chart is None:
source_chart = _first(root, "lineChart")
if plot_area is None or source_chart is None:
return
series = _direct_children(source_chart, "ser")
if len(series) < 2:
return
moving = series[-1]
source_chart.remove(moving)
moving.insert(3, parse_xml(_make_marker_series_props(color, line=line)))
# Line-series marker belongs before category/value references in chart XML.
moving.insert(4, parse_xml(f'<c:marker {nsdecls("c", "a")}><c:symbol val="circle"/><c:size val="6"/>{_make_shape_props(color)}</c:marker>'))
moving.append(parse_xml(f'<c:smooth {nsdecls("c")} val="0"/>'))
primary_cat = _direct_children(source_chart, "axId")[0].get("val")
secondary_val = "90210002"
secondary_cat = primary_cat
line_chart = parse_xml(
f'<c:lineChart {nsdecls("c")}>'
'<c:grouping val="standard"/>'
'<c:varyColors val="0"/>'
f'<c:marker val="1"/>'
'<c:smooth val="0"/>'
f'<c:axId val="{secondary_cat}"/>'
f'<c:axId val="{secondary_val}"/>'
'</c:lineChart>'
)
# Series must appear before marker/smooth/axId elements.
line_chart.insert(2, moving)
_insert_after(plot_area, source_chart, line_chart)
primary_val_axis = _first(root, "valAx")
secondary_axis = parse_xml(
f'<c:valAx {nsdecls("c")}>'
f'<c:axId val="{secondary_val}"/>'
'<c:scaling><c:orientation val="minMax"/></c:scaling>'
'<c:delete val="0"/>'
'<c:axPos val="r"/>'
'<c:majorTickMark val="out"/>'
'<c:minorTickMark val="none"/>'
'<c:tickLblPos val="nextTo"/>'
f'<c:crossAx val="{secondary_cat}"/>'
'<c:crosses val="max"/>'
'</c:valAx>'
)
if primary_val_axis is not None:
_insert_after(plot_area, primary_val_axis, secondary_axis)
else:
plot_area.append(secondary_axis)
_format_combo_axes(
chart,
primary_max=primary_max,
primary_unit=primary_unit,
secondary_min=secondary_min,
secondary_max=secondary_max,
secondary_unit=secondary_unit,
)
def apply_slide12_table(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide12 table target shape not found and no target_box provided")
for shp in list(slide.shapes):
if not getattr(shp, "has_table", False):
continue
if abs(shp.top - top) < 1500000:
remove_shape(shp)
headers = list(item["headers"])
rows = [list(r) for r in item["rows"]]
gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
table = gf.table
base_widths = [92, 128, 144, 110, 144, 110, 122, 144, 110, 144, 110]
if len(headers) == len(base_widths):
total = sum(base_widths)
for i, bw in enumerate(base_widths):
table.columns[i].width = int(width * bw / total)
header_bg = RGBColor(0x5F, 0x6F, 0xCE)
grid = RGBColor(0xD5, 0xD5, 0xD5)
black = RGBColor(0x11, 0x11, 0x11)
white = RGBColor(0xFF, 0xFF, 0xFF)
lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
lfl_neg = RGBColor(0x0F, 0x9D, 0x58)
for c, text in enumerate(headers):
cell = table.cell(0, c)
cell.text = str(text)
cell.fill.solid()
cell.fill.fore_color.rgb = header_bg
set_text_style(cell.text_frame, size=10, bold=True, rgb=white)
for r, row in enumerate(rows, start=1):
for c, value in enumerate(row):
cell = table.cell(r, c)
v = str(value)
cell.text = v
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
color = black
if r == len(rows) and c > 0:
if v.startswith("-"):
color = lfl_neg
elif not v.startswith("0"):
color = lfl_pos
set_text_style(cell.text_frame, size=10, bold=(r == len(rows)), rgb=color)
def add_line_chart(slide, left, top, width, height, panel: dict[str, Any], month: int):
cats = []
cur = panel["current_values"]
prev = panel["previous_values"]
lfl = [float(v) / 100.0 for v in panel["lfl_values"]]
for i in range(len(cur)):
d = i + 1
cats.append(f"{month}-{d}" if d in (1, 8, 16, 23, len(cur)) else "")
data = CategoryChartData()
data.categories = cats
data.add_series(panel["current_name"], tuple(cur))
data.add_series(panel["previous_name"], tuple(prev))
data.add_series("LFL", tuple(lfl))
gf = slide.shapes.add_chart(
XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, data
)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = str(panel["title"])
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.TOP
chart.value_axis.minimum_scale = 0.0
chart.value_axis.maximum_scale = float(panel["value_axis_max"])
chart.value_axis.major_unit = float(panel["value_axis_major_unit"])
chart.value_axis.tick_labels.font.size = Pt(9)
chart.category_axis.tick_labels.font.size = Pt(8)
return gf
def apply_slide12_dual_line(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide12 dual-line target shape not found and no target_box provided")
for shp in list(slide.shapes):
if not getattr(shp, "has_chart", False):
continue
if abs(shp.top - top) < 1800000:
remove_shape(shp)
panels = list(item["panels"])
gap = int(width * 0.01)
panel_w = int((width - gap) / 2)
month = int(item.get("month", 1))
add_line_chart(slide, left, top, panel_w, height, panels[0], month)
add_line_chart(slide, left + panel_w + gap, top, panel_w, height, panels[1], month)
def add_bar_chart(slide, left, top, width, height, entries, report_label, compare_label):
cats = [e["name"] for e in entries]
report_vals = [e["report_uv"] for e in entries]
compare_vals = [e["compare_uv"] for e in entries]
data = CategoryChartData()
data.categories = cats
data.add_series(report_label, tuple(report_vals))
data.add_series(compare_label, tuple(compare_vals))
gf = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, data
)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = "Entry Traffic LFL"
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.TOP
chart.category_axis.tick_labels.font.size = Pt(7)
chart.value_axis.tick_labels.font.size = Pt(8)
return gf
def top_donut_rows(entries: list[dict[str, Any]], field: str):
pairs = [(e["name"], float(e[field])) for e in entries]
pairs.sort(key=lambda x: x[1], reverse=True)
# Keep fewer slices to avoid unreadable, overlapping labels.
top = pairs[:5]
other = sum(v for _, v in pairs[5:])
if other > 0:
top.append(("Other", other))
return top
def add_donut_chart(slide, left, top, width, height, rows, title):
data = CategoryChartData()
data.categories = [r[0] for r in rows]
data.add_series("UV", tuple([r[1] for r in rows]))
gf = slide.shapes.add_chart(XL_CHART_TYPE.DOUGHNUT, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = title
chart.has_legend = False
plot = chart.plots[0]
plot.has_data_labels = True
labels = plot.data_labels
labels.show_category_name = False
labels.show_percentage = False
labels.show_value = False
labels.font.size = Pt(10)
labels.font.bold = True
total = sum(float(v) for _, v in rows)
if total > 0:
series = chart.series[0]
for idx, (name, value) in enumerate(rows):
pct = int(round((float(value) / total) * 100))
point = series.points[idx]
point.data_label.has_text_frame = True
point.data_label.text_frame.text = f"{name}\n{pct}%"
point.data_label.text_frame.paragraphs[0].font.size = Pt(10)
point.data_label.text_frame.paragraphs[0].font.bold = True
return gf
def apply_slide12_entry_mix(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide12 entry-mix target shape not found and no target_box provided")
for shp in list(slide.shapes):
if not getattr(shp, "has_chart", False):
continue
if abs(shp.top - top) < 1800000:
remove_shape(shp)
entries = list(item["entries"])
report_year = int(item["report_year"])
compare_year = int(item["compare_year"])
report_label = f"{str(report_year)[-2:]}UV"
compare_label = f"{str(compare_year)[-2:]}UV"
# Give donut charts much more space for labels.
left_w = int(width * 0.30)
right_w = width - left_w
gap = int(width * 0.01)
donut_w = int((right_w - gap) / 2)
donut_left = left + left_w
add_bar_chart(slide, left, top, left_w, height, entries, report_label, compare_label)
report_rows = top_donut_rows(entries, "report_uv")
compare_rows = top_donut_rows(entries, "compare_uv")
donut_top = max(0, top - int(height * 0.08))
donut_h = int(height * 1.16)
add_donut_chart(slide, donut_left, donut_top, donut_w, donut_h, report_rows, f"{str(report_year)[-2:]} Entry Mix")
add_donut_chart(
slide,
donut_left + donut_w + gap,
donut_top,
donut_w,
donut_h,
compare_rows,
f"{str(compare_year)[-2:]} Entry Mix",
)
def cleanup_shapes_in_zone(slide, left: int, top: int, width: int, height: int, *, charts: bool = False, tables: bool = False, textboxes: bool = False) -> None:
right = left + width
bottom = top + height
pad = 500000
for shp in list(slide.shapes):
keep = True
if charts and getattr(shp, "has_chart", False):
keep = False
if tables and getattr(shp, "has_table", False):
keep = False
if textboxes and getattr(shp, "has_text_frame", False):
keep = False
if keep:
continue
cx = shp.left + int(shp.width / 2)
cy = shp.top + int(shp.height / 2)
if (left - pad) <= cx <= (right + pad) and (top - pad) <= cy <= (bottom + pad):
remove_shape(shp)
def apply_slide14_live_session_summary_table(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide14 summary table target not found and no target_box provided")
cleanup_shapes_in_zone(slide, left, top, width, height, tables=True)
headers = list(item["headers"])
rows = [list(r) for r in item["rows"]]
gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
table = gf.table
base_widths = [90, 116, 108, 98, 92, 120, 116, 116, 128, 118, 116]
if len(headers) == len(base_widths):
total = sum(base_widths)
for i, bw in enumerate(base_widths):
table.columns[i].width = int(width * bw / total)
header_bg = RGBColor(0x5F, 0x6F, 0xCE)
black = RGBColor(0x11, 0x11, 0x11)
white = RGBColor(0xFF, 0xFF, 0xFF)
lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
lfl_neg = RGBColor(0x0F, 0x9D, 0x58)
for c, text in enumerate(headers):
cell = table.cell(0, c)
cell.text = str(text)
cell.fill.solid()
cell.fill.fore_color.rgb = header_bg
set_text_style(cell.text_frame, size=9, bold=True, rgb=white)
for r, row in enumerate(rows, start=1):
for c, value in enumerate(row):
cell = table.cell(r, c)
v = str(value)
cell.text = v
cell.fill.solid()
cell.fill.fore_color.rgb = white
color = black
if r == len(rows) and c > 0:
if v.startswith("-"):
color = lfl_neg
elif v.startswith("+"):
color = lfl_pos
set_text_style(cell.text_frame, size=9, bold=(r == len(rows)), rgb=color)
def add_daily_sales_penetration_chart(
slide,
left: int,
top: int,
width: int,
height: int,
days: list[int],
sales: list[float],
penetration: list[float],
sales_axis_max: float,
sales_axis_step: float,
penetration_axis_max: float,
penetration_axis_step: float,
):
cats = [str(d) if d in (1, 4, 8, 12, 16, 20, 24, 28, len(days)) else "" for d in days]
data = CategoryChartData()
data.categories = cats
data.add_series("Live Sales", tuple(sales))
data.add_series("Penetration", tuple(penetration))
gf = slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = "Daily Live Sales & Penetration"
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.TOP
chart.value_axis.minimum_scale = 0.0
chart.value_axis.maximum_scale = float(sales_axis_max)
chart.value_axis.major_unit = float(sales_axis_step)
chart.value_axis.tick_labels.font.size = Pt(9)
chart.category_axis.tick_labels.font.size = Pt(8)
return gf
def add_daily_conversion_chart(slide, left: int, top: int, width: int, height: int, days: list[int], conv: list[float], axis_max: float, axis_step: float):
cats = [str(d) if d in (1, 4, 8, 12, 16, 20, 24, 28, len(days)) else "" for d in days]
data = CategoryChartData()
data.categories = cats
data.add_series("Conversion", tuple(conv))
gf = slide.shapes.add_chart(XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = "Daily Conversion Rate"
chart.has_legend = False
chart.value_axis.minimum_scale = 0.0
chart.value_axis.maximum_scale = float(axis_max)
chart.value_axis.major_unit = float(axis_step)
chart.value_axis.tick_labels.font.size = Pt(9)
chart.category_axis.tick_labels.font.size = Pt(8)
try:
chart.value_axis.tick_labels.number_format = "0%"
except Exception:
pass
return gf
def add_donut_chart_compact(slide, left: int, top: int, width: int, height: int, rows, title: str):
data = CategoryChartData()
data.categories = [r[0] for r in rows]
data.add_series("Mix", tuple([r[1] for r in rows]))
gf = slide.shapes.add_chart(XL_CHART_TYPE.DOUGHNUT, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = title
chart.has_legend = False
plot = chart.plots[0]
plot.has_data_labels = True
labels = plot.data_labels
labels.show_category_name = True
labels.show_percentage = True
labels.show_value = False
labels.font.size = Pt(7)
try:
chart.series[0].doughnut_hole_size = 68
except Exception:
pass
return gf
def apply_slide14_live_session_trend(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide14 trend target not found and no target_box provided")
cleanup_shapes_in_zone(slide, left, top, width, height, charts=True)
days = [int(v) for v in item["days"]]
sales_values = [float(v) for v in item["sales_values"]]
penetration_values = [float(v) for v in item.get("penetration_values") or item.get("conversion_values")]
sales_axis_max = float(item["sales_axis_max"])
sales_axis_step = float(item["sales_axis_major_unit"])
penetration_axis_max = float(item.get("penetration_axis_max") or item.get("conversion_axis_max"))
penetration_axis_step = float(item.get("penetration_axis_major_unit") or item.get("conversion_axis_major_unit"))
add_daily_sales_penetration_chart(
slide,
left,
top,
width,
height,
days,
sales_values,
penetration_values,
sales_axis_max,
sales_axis_step,
penetration_axis_max,
penetration_axis_step,
)
def add_share_bar_chart(
slide,
left: int,
top: int,
width: int,
height: int,
rows: list[dict[str, Any]],
*,
title: str,
max_scale: float,
):
data = CategoryChartData()
data.categories = [str(r["name"]) for r in rows]
data.add_series("Share", tuple([float(r["share"]) for r in rows]))
gf = slide.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = title
chart.has_legend = False
chart.category_axis.tick_labels.font.size = Pt(8)
chart.value_axis.minimum_scale = 0.0
chart.value_axis.maximum_scale = max_scale
chart.value_axis.major_unit = 0.05
try:
chart.value_axis.tick_labels.number_format = "0%"
except Exception:
pass
plot = chart.plots[0]
plot.has_data_labels = False
return gf
def add_tail_share_bar(slide, left: int, top: int, width: int, height: int, rows: list[dict[str, Any]]):
rows = rows[:6]
data = CategoryChartData()
data.categories = [str(r["name"]) for r in rows]
data.add_series("Share", tuple([float(r["share"]) for r in rows]))
gf = slide.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, data)
chart = gf.chart
chart.has_title = True
chart.chart_title.text_frame.text = "L3 Tail Share"
chart.has_legend = False
chart.category_axis.tick_labels.font.size = Pt(7)
chart.value_axis.minimum_scale = 0.0
chart.value_axis.maximum_scale = 0.2
chart.value_axis.major_unit = 0.05
try:
chart.value_axis.tick_labels.number_format = "0%"
except Exception:
pass
chart.plots[0].has_data_labels = False
return gf
def apply_slide14_goods_category_mix(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide14 goods mix target not found and no target_box provided")
cleanup_shapes_in_zone(slide, left, top, width, height, charts=True)
level1_rows = list(item.get("level1_rows") or [])
level3_top_rows = list(item.get("level3_top_rows") or [])
level3_tail_rows = list(item.get("level3_tail_rows") or [])
gap = int(width * 0.025)
left_w = int(width * 0.31)
mid_w = int(width * 0.37)
right_w = width - left_w - mid_w - gap * 2
donut_rows = [(str(r["name"]), float(r["value"])) for r in level1_rows]
add_donut_chart_compact(slide, left, top, left_w, height, donut_rows, "L1 Mix")
add_share_bar_chart(
slide,
left + left_w + gap,
top,
mid_w,
height,
level3_top_rows,
title="Top5 L3 Share",
max_scale=0.7,
)
add_tail_share_bar(slide, left + left_w + mid_w + gap * 2, top, right_w, height, level3_tail_rows)
def apply_slide15_goods_summary_table(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide15 summary table target not found and no target_box provided")
cleanup_shapes_in_zone(slide, left, top, width, height, tables=True)
headers = list(item["headers"])
rows = [list(r) for r in item["rows"]]
gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
table = gf.table
base_widths = [56, 70, 74, 78, 68, 72, 110, 70, 74, 92, 92, 96, 66, 72]
if len(headers) == len(base_widths):
total = sum(base_widths)
for i, bw in enumerate(base_widths):
table.columns[i].width = int(width * bw / total)
header_bg = RGBColor(0x5F, 0x6F, 0xCE)
ttl_bg = RGBColor(0xC4, 0xC6, 0xEB)
black = RGBColor(0x11, 0x11, 0x11)
white = RGBColor(0xFF, 0xFF, 0xFF)
lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
lfl_neg = RGBColor(0x0F, 0x9D, 0x58)
lfl_cols = {5, 8, 11, 13}
for c, text in enumerate(headers):
cell = table.cell(0, c)
cell.text = str(text)
cell.fill.solid()
cell.fill.fore_color.rgb = header_bg
set_text_style(cell.text_frame, size=8, bold=True, rgb=white)
for r, row in enumerate(rows, start=1):
is_ttl = str(row[0]).upper() == "TTL"
for c, value in enumerate(row):
cell = table.cell(r, c)
v = str(value)
cell.text = v
cell.fill.solid()
cell.fill.fore_color.rgb = ttl_bg if is_ttl else white
color = black
if c in lfl_cols:
if v.startswith("-"):
color = lfl_neg
elif v.startswith("+"):
color = lfl_pos
set_text_style(cell.text_frame, size=9, bold=is_ttl, rgb=color)
def apply_slide15_top5_tables(slide, item: dict[str, Any]) -> None:
shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
if shape is not None:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
remove_shape(shape)
else:
left, top, width, height = get_target_box(item)
if width <= 0 or height <= 0:
raise RuntimeError("slide15 top5 target not found and no target_box provided")
cleanup_shapes_in_zone(slide, left, top, width, height, tables=True, textboxes=True)
bags_rows = list(item.get("bags_rows") or [])
shoes_rows = list(item.get("shoes_rows") or [])
gap = int(width * 0.025)
panel_w = int((width - gap) / 2)
title_h = int(height * 0.13)
table_top = top + title_h
table_h = height - title_h
title1 = slide.shapes.add_textbox(left, top, panel_w, title_h)
title1.text_frame.text = "BAGS TOP5"
set_text_style(title1.text_frame, size=14, bold=True, rgb=RGBColor(0x11, 0x11, 0x11))
title2 = slide.shapes.add_textbox(left + panel_w + gap, top, panel_w, title_h)
title2.text_frame.text = "SHOES TOP5"
set_text_style(title2.text_frame, size=14, bold=True, rgb=RGBColor(0x11, 0x11, 0x11))
def add_top5_table(x: int, rows: list[dict[str, Any]]):
headers = ["Image", "Article", "QTY", "Note"]
table_shape = slide.shapes.add_table(6, 4, x, table_top, panel_w, table_h)
table = table_shape.table
col_widths = [26, 38, 14, 22]
total = sum(col_widths)
for i, cw in enumerate(col_widths):
table.columns[i].width = int(panel_w * cw / total)
header_bg = RGBColor(0x72, 0x79, 0xDE)
white = RGBColor(0xFF, 0xFF, 0xFF)
black = RGBColor(0x11, 0x11, 0x11)
for c, h in enumerate(headers):
cell = table.cell(0, c)
cell.text = h
cell.fill.solid()
cell.fill.fore_color.rgb = header_bg
set_text_style(cell.text_frame, size=10, bold=True, rgb=white)
for r in range(1, 6):
row = rows[r - 1] if r - 1 < len(rows) else {"article": "", "qty": "", "note": "", "image_path": ""}
vals = ["", str(row.get("article", "")), str(row.get("qty", "")), str(row.get("note", ""))]
for c, val in enumerate(vals):
cell = table.cell(r, c)
cell.text = val
cell.fill.solid()
cell.fill.fore_color.rgb = white
align = PP_ALIGN.LEFT if c in (1, 3) else PP_ALIGN.CENTER
set_text_style(cell.text_frame, size=8 if c in (1, 3) else 9, bold=False, rgb=black, align=align)
image_path = str(row.get("image_path") or "")
cell = table.cell(r, 0)
image_left = x
for col_idx in range(0):
image_left += table.columns[col_idx].width
image_top = table_top + int(table_h / 6) * r
image_w = table.columns[0].width
image_h = int(table_h / 6)
pad = int(min(image_w, image_h) * 0.08)
if image_path and Path(image_path).exists():
slide.shapes.add_picture(image_path, image_left + pad, image_top + pad, image_w - pad * 2, image_h - pad * 2)
else:
rect = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, image_left + pad, image_top + pad, image_w - pad * 2, image_h - pad * 2)
rect.fill.solid()
rect.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2)
rect.line.color.rgb = RGBColor(0xD0, 0xD0, 0xD0)
add_top5_table(left, bags_rows)
add_top5_table(left + panel_w + gap, shoes_rows)
def apply_native_ops(prs: Presentation, ops: list[dict[str, Any]]) -> None:
for item in ops:
slide = prs.slides[int(item["slide"]) - 1]
typ = item.get("type")
if typ == "slide12_traffic_table":
apply_slide12_table(slide, item)
elif typ == "slide12_dual_line_lfl":
apply_slide12_dual_line(slide, item)
elif typ == "slide12_entry_mix":
apply_slide12_entry_mix(slide, item)
elif typ == "slide14_live_session_summary_table":
apply_slide14_live_session_summary_table(slide, item)
elif typ == "slide14_live_session_trend":
apply_slide14_live_session_trend(slide, item)
elif typ == "slide14_goods_category_mix":
apply_slide14_goods_category_mix(slide, item)
elif typ == "slide15_goods_summary_table":
apply_slide15_goods_summary_table(slide, item)
elif typ == "slide15_top5_tables":
apply_slide15_top5_tables(slide, item)
else:
raise RuntimeError(f"Unsupported native op type in openxml fallback: {typ}")
def main() -> None:
args = parse_args()
template = Path(args.template).resolve()
output = Path(args.output).resolve()
ops_path = Path(args.operations).resolve()
if not template.exists():
raise SystemExit(f"Template not found: {template}")
if not ops_path.exists():
raise SystemExit(f"Operations not found: {ops_path}")
if template != output:
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template, output)
ops = json.loads(ops_path.read_text(encoding="utf-8"))
text_ops = list(ops.get("replace_text") or [])
native_ops = list(ops.get("replace_native_charts") or [])
if not text_ops and not native_ops:
output.parent.mkdir(parents=True, exist_ok=True)
if template != output:
print(str(output))
else:
print(str(template))
return
prs = Presentation(str(output))
apply_text_ops(prs, text_ops)
apply_native_ops(prs, native_ops)
tmp_out = output.with_name(output.stem + ".tmp-render.pptx")
prs.save(str(tmp_out))
final_out = output
try:
if output.exists():
output.unlink()
tmp_out.replace(output)
except PermissionError:
final_out = output.with_name(output.stem + ".rendered.pptx")
if final_out.exists():
final_out.unlink()
tmp_out.replace(final_out)
print(str(final_out))
if __name__ == "__main__":
main()
......@@ -52,7 +52,7 @@ def main() -> None:
]
if args.config_source == "yaml":
config_path = args.config or str((root.parent / "config.yaml").resolve())
common = ["--config", config_path]
common = ["--config-source", "yaml", "--config", config_path]
runtime_cfg = load_runtime_config(args, default_config=Path(args.config) if args.config else (root.parent / "config.yaml"))
workdir = Path(runtime_cfg["paths"]["workdir"]).resolve()
......@@ -65,20 +65,10 @@ def main() -> None:
args.download_dir,
],
[args.python, str(root / "load_traffic_downloads_to_mysql.py"), *common],
[args.python, str(root / "sync_cep_traffic_assets.py"), *common],
[args.python, str(root / "sync_cep_site_traffic_entry_assets.py"), *common],
[args.python, str(root / "sync_cep_traffic_summary_assets.py"), *common],
[
args.python,
str(root / "compose_uv_lfl_report.py"),
"--table-image",
str(workdir / "assets" / "cep-traffic" / "cep-traffic-summary.png"),
"--line-image",
str(workdir / "assets" / "cep-site-traffic-entry" / "cep-site-traffic-entry-lfl.png"),
"--entry-image",
str(workdir / "assets" / "cep-traffic-summary" / "cep-traffic-summary-lfl.png"),
"--output",
str(workdir / "assets" / "uv-lfl" / "uv-lfl-report.png"),
str(root / "build_slide12_editable_ops.py"),
*common,
],
]
......@@ -102,8 +92,8 @@ def main() -> None:
f"stderr_tail=\n{err_tail}\n"
)
final_image = workdir / "assets" / "uv-lfl" / "uv-lfl-report.png"
print(f"FINAL_IMAGE={final_image}")
final_ops = workdir / "render-ops.uv-lfl-s12.editable.json"
print(f"FINAL_OPS={final_ops}")
print("FLOW12_PIPELINE_OK")
......
......@@ -25,7 +25,8 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--mysql-password", default="ck@123456")
p.add_argument("--mysql-database", default="ckc_cep_db_test")
p.add_argument("--download-dir", default=r"C:\vipdata")
p.add_argument("--skip-compose", action="store_true", help="Only download/import/render 2 images, skip final compose")
p.add_argument("--skip-compose", action="store_true", help="Deprecated. Compose preview is disabled by default.")
p.add_argument("--skip-editable-ops", action="store_true", help="Skip generating slide14 editable ops.")
return p.parse_args()
......@@ -50,7 +51,7 @@ def build_common_args(args: argparse.Namespace) -> list[str]:
]
if args.config_source == "yaml":
config_path = args.config or str((SCRIPT_DIR.parent / "config.yaml").resolve())
return ["--config", config_path]
return ["--config-source", "yaml", "--config", config_path]
return common
......@@ -97,20 +98,14 @@ def main() -> None:
[args.python, str(root / "sync_cep_live_session_trend_assets.py"), *common],
]
if not args.skip_compose:
if not args.skip_editable_ops:
steps.append(
[
args.python,
str(root / "compose_live_session_report.py"),
str(root / "build_slide14_15_editable_ops.py"),
*common,
"--summary-image",
str(workdir / "assets" / "cep-live-session" / "cep-live-session-summary.png"),
"--trend-image",
str(workdir / "assets" / "cep-live-session" / "cep-live-session-sales-penetration-trend.png"),
"--pivot-image",
str(workdir / "assets" / "cep-live-goods-effect" / "cep-live-goods-effect-pivot.png"),
"--output",
str(workdir / "assets" / "cep-live-session" / "cep-live-session-combined.png"),
"--slides",
"14",
]
)
......@@ -118,10 +113,9 @@ def main() -> None:
for i, cmd in enumerate(steps, start=1):
run_step(i, total, cmd)
print(f"FINAL_IMAGE={workdir / 'assets' / 'cep-live-session' / 'cep-live-session-combined.png'}")
print(f"FINAL_OPS={workdir / 'render-ops.flow14-15.editable.json'}")
print("FLOW14_PIPELINE_OK")
if __name__ == "__main__":
main()
......@@ -3,7 +3,9 @@ from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
......@@ -12,6 +14,12 @@ if str(SCRIPT_DIR) not in sys.path:
from runtime_config import load_runtime_config
@dataclass(frozen=True)
class MonthKey:
year: int
month: int
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Run flow15 live-goods-effect pipeline in one command.")
p.add_argument("--python", default=sys.executable, help="Python executable path")
......@@ -25,14 +33,17 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--mysql-password", default="ck@123456")
p.add_argument("--mysql-database", default="ckc_cep_db_test")
p.add_argument("--download-dir", default=r"C:\vipdata")
p.add_argument("--skip-compose", action="store_true")
p.add_argument("--report-year", type=int, default=0)
p.add_argument("--report-month", default="")
p.add_argument("--skip-compose", action="store_true", help="Deprecated. Compose preview is disabled by default.")
p.add_argument("--skip-editable-ops", action="store_true", help="Skip generating slide15 editable ops.")
return p.parse_args()
def build_common_args(args: argparse.Namespace) -> list[str]:
if args.config_source == "yaml":
config_path = args.config or str((SCRIPT_DIR.parent / "config.yaml").resolve())
return ["--config", config_path]
return ["--config-source", "yaml", "--config", config_path]
return [
"--config-source",
args.config_source,
......@@ -53,6 +64,41 @@ def build_common_args(args: argparse.Namespace) -> list[str]:
]
def parse_month_number(raw: Any) -> int:
if raw is None:
return 1
if isinstance(raw, int):
return min(12, max(1, raw))
text = str(raw).strip()
import re
m = re.search(r"(1[0-2]|0?[1-9])", text)
if m:
return int(m.group(1))
return 1
def previous_month(m: MonthKey) -> MonthKey:
if m.month == 1:
return MonthKey(m.year - 1, 12)
return MonthKey(m.year, m.month - 1)
def same_month_last_year(m: MonthKey) -> MonthKey:
return MonthKey(m.year - 1, m.month)
def resolve_report_month(args: argparse.Namespace, runtime_cfg: dict[str, Any]) -> MonthKey:
report_cfg = runtime_cfg.get("report", {})
year = int(args.report_year or report_cfg.get("year", 2026))
month = parse_month_number(args.report_month or report_cfg.get("month_cn", "1"))
return MonthKey(year, month)
def with_report_period(cmd: list[str], month: MonthKey) -> list[str]:
return [*cmd, "--report-year", str(month.year), "--report-month", str(month.month)]
def run_step(step_index: int, total: int, cmd: list[str]) -> None:
print(f"[STEP {step_index}/{total}] {' '.join(cmd)}")
result = subprocess.run(cmd, text=True, capture_output=True)
......@@ -82,40 +128,48 @@ def main() -> None:
default_config=Path(args.config) if args.config else (root.parent / "config.yaml"),
)
workdir = Path(runtime_cfg["paths"]["workdir"]).resolve()
report_month = resolve_report_month(args, runtime_cfg)
current_month = report_month
compare_month = same_month_last_year(report_month)
steps: list[list[str]] = [
[
args.python,
str(root / "sync_vis_vendor_goods_effect_download.py"),
*common,
"--download-dir",
args.download_dir,
"--no-auto-load",
],
[
args.python,
str(root / "load_vis_vendor_goods_effect_to_mysql.py"),
*common,
"--download-dir",
args.download_dir,
],
[args.python, str(root / "sync_cep_live_goods_effect_summary_assets.py"), *common],
[args.python, str(root / "sync_cep_live_goods_effect_pivot_assets.py"), *common],
[args.python, str(root / "sync_cep_live_goods_effect_top5_assets.py"), *common],
with_report_period(
[
args.python,
str(root / "sync_vis_vendor_goods_effect_download.py"),
*common,
"--download-dir",
args.download_dir,
],
current_month,
),
with_report_period(
[
args.python,
str(root / "sync_vis_vendor_goods_effect_download.py"),
*common,
"--download-dir",
args.download_dir,
],
compare_month,
),
with_report_period([args.python, str(root / "sync_cep_live_goods_effect_summary_assets.py"), *common], report_month),
with_report_period([args.python, str(root / "sync_cep_live_goods_effect_pivot_assets.py"), *common], current_month),
with_report_period([args.python, str(root / "sync_cep_live_goods_effect_top5_assets.py"), *common], current_month),
]
if not args.skip_compose:
if not args.skip_editable_ops:
steps.append(
[
args.python,
str(root / "compose_live_goods_effect_report.py"),
str(root / "build_slide14_15_editable_ops.py"),
*common,
"--summary-image",
str(workdir / "assets" / "cep-live-goods-effect" / "cep-live-goods-effect-summary.png"),
"--top5-image",
str(workdir / "assets" / "cep-live-goods-effect" / "cep-live-goods-effect-top5.png"),
"--output",
str(workdir / "assets" / "cep-live-goods-effect" / "cep-live-goods-effect-combined.png"),
"--slides",
"15",
"--report-year",
str(report_month.year),
"--report-month",
str(report_month.month),
]
)
......@@ -123,10 +177,9 @@ def main() -> None:
for i, cmd in enumerate(steps, start=1):
run_step(i, total, cmd)
print(f"FINAL_IMAGE={workdir / 'assets' / 'cep-live-goods-effect' / 'cep-live-goods-effect-combined.png'}")
print(f"FINAL_OPS={workdir / 'render-ops.flow14-15.editable.json'}")
print("FLOW15_PIPELINE_OK")
if __name__ == "__main__":
main()
......@@ -32,7 +32,7 @@ def parse_args() -> argparse.Namespace:
def build_common_args(args: argparse.Namespace) -> list[str]:
if args.config_source == "yaml":
config_path = args.config or str((SCRIPT_DIR.parent / "config.yaml").resolve())
return ["--config", config_path]
return ["--config-source", "yaml", "--config", config_path]
return [
"--config-source",
args.config_source,
......
......@@ -280,8 +280,7 @@ def main() -> None:
config_path = Path(args.config or r"D:\projects\skills\vip-report\config.yaml").resolve()
config = load_runtime_config(args, default_config=config_path)
curr_month = resolve_report_month(args, config)
prev_month = previous_month(curr_month)
prev2_month = same_month_last_year(curr_month)
compare_month = same_month_last_year(curr_month)
mysql_cfg = config["mysql"]
mysql_user = mysql_cfg.get("username") or mysql_cfg.get("user")
......@@ -296,11 +295,7 @@ def main() -> None:
)
try:
curr_raw = fetch_month_rows(conn, curr_month.ym)
prev_raw = fetch_month_rows(conn, prev_month.ym)
compare_month = prev_month.ym
if not prev_raw:
prev_raw = fetch_month_rows(conn, prev2_month.ym)
compare_month = prev2_month.ym
prev_raw = fetch_month_rows(conn, compare_month.ym)
finally:
conn.close()
......@@ -320,7 +315,7 @@ def main() -> None:
operations_path.write_text(json.dumps(operations, ensure_ascii=False, indent=2), encoding="utf-8")
manifest = {
"source": {"table": TABLE_NAME, "current_month": curr_month.ym, "compare_month": compare_month, "config_path": str(config_path)},
"source": {"table": TABLE_NAME, "current_month": curr_month.ym, "compare_month": compare_month.ym, "config_path": str(config_path)},
"asset_path": str(image_path),
"operations_path": str(operations_path),
"metrics": metrics,
......
......@@ -243,7 +243,8 @@ def render_image(output_path: Path, prev_key: MonthKey, prev: Agg, curr_key: Mon
table_w = sum(col_widths)
table_h = row_h * 4
img = Image.new("RGB", (1126, 160), "#e7e7e7")
image_w = max(1126, table_x + table_w + 28)
img = Image.new("RGB", (image_w, 160), "#e7e7e7")
draw = ImageDraw.Draw(img)
font_title = load_font(38, bold=True)
......@@ -257,7 +258,9 @@ def render_image(output_path: Path, prev_key: MonthKey, prev: Agg, curr_key: Mon
draw.line((16, 43, 150, 43), fill="#cf2323", width=2)
# Brand text
draw.text((945, 8), "CHARLES & KEITH", fill="#cfcfcf", font=font_brand)
brand_text = "CHARLES & KEITH"
brand_bbox = draw.textbbox((0, 0), brand_text, font=font_brand)
draw.text((image_w - (brand_bbox[2] - brand_bbox[0]) - 18, 8), brand_text, fill="#cfcfcf", font=font_brand)
# Table header bg
header_bg = "#6c72df"
......
from __future__ import annotations
import argparse
import base64
import json
import os
import re
......@@ -202,23 +203,40 @@ def build_js(spec: dict[str, Any]) -> str:
return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 1 && r.height > 1;
}};
const text = (el) => ((el && el.textContent) || '').replace(/\\s+/g, ' ').trim();
const btn = Array.from(document.querySelectorAll('button,span,a,div')).find((el) => visible(el) && text(el) === '下载数据');
if (!btn) return false;
btn.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
return true;
const cards = Array.from(document.querySelectorAll('.g-card')).filter(visible);
const detailCard = cards.find((card) => text(card).includes('拒退商品明细'))
|| cards.find((card) => text(card).includes('款号') && text(card).includes('下载数据'))
|| null;
const root = detailCard || document;
const candidates = Array.from(root.querySelectorAll('.ad-toolbar-box button, .ad-toolbar-box span, button'))
.filter((el) => visible(el) && text(el) === '下载数据');
const btn = candidates.find((el) => el.closest('.ad-toolbar-box')) || candidates[0];
if (!btn) {{
return {{
ok: false,
cardTexts: cards.map((card) => text(card).slice(0, 160)).slice(0, 8),
}};
}}
const clickable = btn.closest('button') || btn;
clickable.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }}));
clickable.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }}));
clickable.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
return {{
ok: true,
cardText: detailCard ? text(detailCard).slice(0, 240) : '',
buttonText: text(clickable),
}};
}});
if (!clickDownload) {{
return {{ ok: false, code: 'DOWNLOAD_DATA_BUTTON_NOT_FOUND', url: page.url() }};
if (!clickDownload.ok) {{
return {{ ok: false, code: 'DOWNLOAD_DATA_BUTTON_NOT_FOUND', clickDownload, url: page.url() }};
}}
await sleep(600);
await sleep(1000);
// click 不需要 in optional modal
try {{
const noNeed = ctx.locator('button:has-text("不需要"), span:has-text("不需要")').first();
if (await noNeed.count().catch(() => 0)) {{
await noNeed.click({{ timeout: 2500 }}).catch(() => null);
await sleep(400);
}}
const noNeed = ctx.locator('.el-message-box button:has-text("不需要"), .el-dialog button:has-text("不需要"), button:has-text("不需要")').first();
await noNeed.click({{ timeout: 15000, force: true }}).catch(() => null);
await sleep(1500);
}} catch {{}}
await page.goto('https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter', {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
......@@ -237,6 +255,7 @@ def build_js(spec: dict[str, Any]) -> str:
}}
await sleep(3500);
const rowsSeen = [];
for (let i = 0; i < spec.max_retries; i++) {{
const queryBtn = page.locator('button:has-text("查询"), span:has-text("查询")').first();
if (await queryBtn.count().catch(() => 0)) {{
......@@ -244,43 +263,74 @@ def build_js(spec: dict[str, Any]) -> str:
}}
await sleep(2000);
const statusCell = page.locator('tbody tr:nth-child(1) td').nth(5);
const statusText = await statusCell.innerText().catch(() => '');
if (!/执行成功|成功/.test(statusText || '')) {{
await sleep(700);
continue;
}}
const rowCount = await page.locator('tbody tr').count().catch(() => 0);
for (let idx = 0; idx < Math.min(rowCount, 10); idx++) {{
const row = page.locator('tbody tr').nth(idx);
const rowText = (await row.innerText().catch(() => '')).replace(/\\s+/g, ' ').trim();
const fileSpan = row.locator('.status-name-span').first();
let uiFileName = await fileSpan.getAttribute('title').catch(() => '');
if (!uiFileName) {{
uiFileName = (await fileSpan.innerText().catch(() => '')).trim();
}}
rowsSeen.push({{ row: idx + 1, uiFileName, rowText }});
if (!uiFileName.includes('款号粒度') || !/执行成功|成功/.test(rowText || '')) {{
continue;
}}
const fileSpan = page.locator('tbody tr:nth-child(1) .status-name-span').first();
let uiFileName = await fileSpan.getAttribute('title').catch(() => '');
if (!uiFileName) {{
uiFileName = (await fileSpan.innerText().catch(() => '')).trim();
}}
const taskId = (rowText.match(/^\\s*(\\d+)/) || [])[1] || '';
const downloadBtn = row.locator('button:has-text("下载"), span:has-text("下载")').first();
if (!(await downloadBtn.count().catch(() => 0))) {{
continue;
}}
const downloadBtn = page.locator('tbody tr:nth-child(1) td button:has-text("下载"), tbody tr:nth-child(1) td span:has-text("下载")').first();
if (!(await downloadBtn.count().catch(() => 0))) {{
continue;
if (!taskId) {{
continue;
}}
const filePayload = await page.evaluate(async (tid) => {{
const body = new URLSearchParams();
body.set('reqData', JSON.stringify({{ taskId: tid }}));
const res = await fetch('/report/asyncdownload/getOutputFile', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},
body: body.toString(),
credentials: 'include',
}});
const ab = await res.arrayBuffer();
const bytes = new Uint8Array(ab);
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {{
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}}
return {{
ok: res.ok,
status: res.status,
contentType: res.headers.get('content-type') || '',
disposition: res.headers.get('content-disposition') || '',
base64: btoa(binary),
}};
}}, taskId);
if (!filePayload.ok) {{
throw new Error(`getOutputFile failed status=${{filePayload.status}} taskId=${{taskId}}`);
}}
const finalName = safeName(`${{spec.prefix}}_${{uiFileName || taskId + '.xlsx'}}`);
const targetPath = `${{spec.download_dir.replace(/\\\\/g, '/')}}/${{finalName}}`;
return {{
ok: true,
url: page.url(),
row: idx + 1,
taskId,
rowText,
uiFileName,
contentType: filePayload.contentType,
disposition: filePayload.disposition,
contentBase64: filePayload.base64,
savedAs: targetPath
}};
}}
const [dl] = await Promise.all([
page.waitForEvent('download', {{ timeout: 120000 }}),
downloadBtn.click({{ timeout: 3000 }}),
]);
const suggested = dl.suggestedFilename();
const finalName = safeName(`${{spec.prefix}}_${{uiFileName || suggested}}`);
const targetPath = `${{spec.download_dir.replace(/\\\\/g, '/')}}/${{finalName}}`;
await dl.saveAs(targetPath);
return {{
ok: true,
url: page.url(),
statusText,
uiFileName,
suggestedFilename: suggested,
savedAs: targetPath
}};
}}
return {{ ok: false, code: 'DOWNLOAD_NOT_READY', url: page.url() }};
return {{ ok: false, code: 'GOODS_STYLE_FILE_NOT_READY', rowsSeen: rowsSeen.slice(0, 40), url: page.url() }};
}}
"""
......@@ -344,7 +394,12 @@ def main() -> None:
result = json.loads(json_line)
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
print(json.dumps(result, ensure_ascii=False))
content_b64 = result.pop("contentBase64", "")
if content_b64:
target = Path(result["savedAs"]).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(content_b64))
print(json.dumps(result, ensure_ascii=True))
if __name__ == "__main__":
......
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
import yaml
NPX_EXECUTABLE = shutil.which("npx.cmd") or shutil.which("npx") or "npx"
PLAYWRIGHT_CMD = [NPX_EXECUTABLE, "--yes", "--package", "@playwright/cli", "playwright-cli"]
SESSION_NAME = "vip-report-monthly-sales"
TARGET_URL = "https://compass.vip.com/frontend/index.html#/rejectionAnalysis/index"
DOWNLOAD_CENTER_URL = "https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter"
DEFAULT_DOWNLOAD_DIR = r"C:\vipdata"
def run_cmd(args: list[str], *, cwd: Path, timeout: int = 300) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
npm_cache = cwd / ".npm-cache"
npm_cache.mkdir(parents=True, exist_ok=True)
env["NPM_CONFIG_CACHE"] = str(npm_cache)
env["npm_config_cache"] = str(npm_cache)
return subprocess.run(
args,
cwd=str(cwd),
text=True,
encoding="utf-8",
errors="replace",
capture_output=True,
timeout=timeout,
check=True,
env=env,
)
def parse_month_number(raw: Any) -> int:
if raw is None:
return 1
if isinstance(raw, int):
return min(12, max(1, raw))
m = re.search(r"(1[0-2]|0?[1-9])", str(raw))
return int(m.group(1)) if m else 1
def resolve_period(config_path: Path, report_year_arg: int, report_month_arg: str) -> tuple[int, int]:
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
report_cfg = cfg.get("report", {})
year = int(report_year_arg or report_cfg.get("year", 2026))
month = parse_month_number(report_month_arg or report_cfg.get("month_cn", "1"))
return year, month
def build_js(spec: dict[str, Any]) -> str:
payload = json.dumps(spec, ensure_ascii=False)
return f"""async function(page) {{
const spec = {payload};
const sleep = (ms) => page.waitForTimeout(ms);
const safeName = (name) => String(name || '')
.replace(/[\\\\/:*?"<>|]/g, '_')
.replace(/\\s+/g, '_')
.replace(/_+/g, '_');
await page.goto(`${{spec.url}}?t=${{Date.now()}}`, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
await sleep(5000);
const tab = page.locator('.el-tabs__item:has-text("拒退商品分析")').first();
if (!(await tab.count().catch(() => 0))) {{
return {{ ok: false, code: 'GOODS_TAB_NOT_FOUND', url: page.url() }};
}}
await tab.click({{ timeout: 5000, force: true }});
await sleep(2500);
const activeText = await page.evaluate(() => {{
const active = document.querySelector('.el-tabs__item.is-active');
return (active?.textContent || '').replace(/\\s+/g, ' ').trim();
}}).catch(() => '');
if (!activeText.includes('拒退商品分析')) {{
return {{ ok: false, code: 'GOODS_TAB_NOT_ACTIVE', activeText, url: page.url() }};
}}
const downloadBtn = page.locator('button:has-text("下载数据")').first();
if (!(await downloadBtn.count().catch(() => 0))) {{
return {{ ok: false, code: 'DOWNLOAD_DATA_BUTTON_NOT_FOUND', activeText, url: page.url() }};
}}
await downloadBtn.click({{ timeout: 5000, force: true }});
await sleep(700);
const noNeed = page.locator('button:has-text("不需要"), span:has-text("不需要")').first();
if (await noNeed.count().catch(() => 0)) {{
await noNeed.click({{ timeout: 3000, force: true }}).catch(() => null);
await sleep(700);
}}
await page.goto(spec.download_center_url, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
await sleep(2000);
const rowsSeen = [];
for (let attempt = 0; attempt < spec.max_retries; attempt++) {{
const queryBtn = page.locator('button:has-text("查询"), span:has-text("查询")').first();
if (await queryBtn.count().catch(() => 0)) {{
await queryBtn.click({{ timeout: 2500 }}).catch(() => null);
}}
await sleep(2200);
const rowCount = await page.locator('tbody tr').count().catch(() => 0);
for (let idx = 0; idx < Math.min(rowCount, 10); idx++) {{
const row = page.locator('tbody tr').nth(idx);
const text = (await row.innerText().catch(() => '')).replace(/\\s+/g, ' ').trim();
const nameEl = row.locator('.status-name-span').first();
let fileName = await nameEl.getAttribute('title').catch(() => '');
if (!fileName) fileName = (await nameEl.innerText().catch(() => '')).trim();
rowsSeen.push({{ row: idx + 1, fileName, text }});
if (!fileName.includes('款号粒度') || !/执行成功|成功/.test(text)) continue;
const btn = row.locator('button:has-text("下载"), span:has-text("下载")').first();
if (!(await btn.count().catch(() => 0))) continue;
const [dl] = await Promise.all([
page.waitForEvent('download', {{ timeout: 120000 }}),
btn.click({{ timeout: 5000, force: true }}),
]);
const suggested = dl.suggestedFilename();
const finalName = safeName(`${{spec.prefix}}_${{fileName || suggested}}`);
const targetPath = `${{spec.download_dir.replace(/\\\\/g, '/')}}/${{finalName}}`;
await dl.saveAs(targetPath);
return {{ ok: true, savedAs: targetPath, fileName, suggestedFilename: suggested, rowText: text }};
}}
}}
return {{ ok: false, code: 'GOODS_STYLE_FILE_NOT_READY', rowsSeen: rowsSeen.slice(0, 30), url: page.url() }};
}}
"""
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Download Compass rejection goods style-granularity file.")
p.add_argument("--config", default=r"D:\projects\skills\vip-report\config.yaml")
p.add_argument("--session", default=SESSION_NAME)
p.add_argument("--report-year", type=int, default=0)
p.add_argument("--report-month", default="")
p.add_argument("--download-dir", default=DEFAULT_DOWNLOAD_DIR)
p.add_argument("--max-retries", type=int, default=30)
return p.parse_args()
def main() -> None:
args = parse_args()
config_path = Path(args.config).resolve()
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
workdir = Path(cfg["paths"]["workdir"]).resolve()
year, month = resolve_period(config_path, args.report_year, args.report_month)
prefix = f"{year}{month:02d}"
download_dir = Path(args.download_dir).resolve()
download_dir.mkdir(parents=True, exist_ok=True)
js_path = workdir / f"tmp-compass-rejection-goods-style-download-{prefix}-{time.time_ns()}.js"
js_path.write_text(
build_js(
{
"url": TARGET_URL,
"download_center_url": DOWNLOAD_CENTER_URL,
"prefix": prefix,
"download_dir": str(download_dir),
"max_retries": int(args.max_retries),
}
),
encoding="utf-8",
)
try:
out = run_cmd(
PLAYWRIGHT_CMD + ["--session", args.session, "run-code", "--filename", str(js_path)],
cwd=workdir,
timeout=1200,
).stdout
finally:
js_path.unlink(missing_ok=True)
if "### Error" in out:
raise RuntimeError(out.strip())
json_line = ""
for line in out.splitlines():
s = line.strip()
if s.startswith("{") and s.endswith("}"):
json_line = s
break
if not json_line:
raise RuntimeError(f"No JSON result from run-code: {out}")
result = json.loads(json_line)
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
from __future__ import annotations
import argparse
import base64
import json
import os
import re
......@@ -309,18 +310,39 @@ def build_download_js(spec: dict[str, Any]) -> str:
return rows.find(r=>r.id===String(tid)) || null;
}}, taskId);
if(hit && hit.hasDl && hit.status.includes('成功')){{
const btn = page.locator(`tbody tr:nth-child(${{hit.row}}) td:nth-child(7) button:has-text("下载")`).first();
if(hit && hit.hasDl && /成功|执行成功|鎴愬姛/.test(hit.status)){{
const sanitize=(s)=>String(s||'').replace(/[\\\\/:*?"<>|]/g,'_').replace(/\\s+/g,' ').trim();
const [dl] = await Promise.all([page.waitForEvent('download',{{timeout:60000}}), btn.click()]);
const suggestedRaw = sanitize(await dl.suggestedFilename());
const byTaskName = sanitize(hit.name || '');
const base = byTaskName || suggestedRaw || String(taskId || 'download');
const base = byTaskName || String(taskId || 'download');
const ensured = /\\.(xlsx|xls|csv)$/i.test(base) ? base : `${{base}}.xlsx`;
const finalName = `${{prefix}}_${{kind}}_${{ensured}}`;
const path = `${{SAVE}}/${{finalName}}`;
await dl.saveAs(path);
return {{taskId, taskType, taskName:hit.name, path}};
const filePayload = await page.evaluate(async (tid) => {{
const body = new URLSearchParams();
body.set('reqData', JSON.stringify({{ taskId: tid }}));
const res = await fetch('/report/asyncdownload/getOutputFile', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},
body: body.toString(),
credentials: 'include',
}});
const ab = await res.arrayBuffer();
const bytes = new Uint8Array(ab);
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {{
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}}
return {{
ok: res.ok,
status: res.status,
contentType: res.headers.get('content-type') || '',
disposition: res.headers.get('content-disposition') || '',
base64: btoa(binary),
}};
}}, String(taskId));
if(!filePayload.ok) throw new Error(`getOutputFile failed status=${{filePayload.status}} taskId=${{taskId}}`);
return {{taskId, taskType, taskName:hit.name, path, contentType:filePayload.contentType, disposition:filePayload.disposition, contentBase64:filePayload.base64}};
}}
}}
throw new Error(`downloadByTaskId timeout taskType=${{taskType}} taskId=${{taskId}}`);
......@@ -432,9 +454,16 @@ def main() -> None:
if not json_line:
raise RuntimeError(f"No JSON result from run-code: {out}")
result = json.loads(json_line)
for item in result.get("done", []):
content_b64 = item.pop("contentBase64", "")
if not content_b64:
continue
target = Path(item["path"])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(content_b64))
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
print(json.dumps(result, ensure_ascii=False))
raise RuntimeError(json.dumps(result, ensure_ascii=True))
print(json.dumps(result, ensure_ascii=True))
if __name__ == "__main__":
......
......@@ -301,8 +301,8 @@ def build_subcategory_filters(
def build_s08_filters(report_month: str, report_year: int) -> list[dict[str, Any]]:
"""S08 works only with the current report year and current report month on live Tableau."""
month_index = resolve_month_index(report_month)
"""S08 is a current-month composition table in the business deck."""
month_value_variants = build_month_value_variants(report_month, cumulative=False)
return [
{
"label": "storename",
......@@ -327,11 +327,7 @@ def build_s08_filters(report_month: str, report_year: int) -> list[dict[str, Any
{
"label": "month",
"field_candidates": ["月(billdate)", "billdate 月", "Month", "month", "月"],
"value_candidates": [
[str(month_index)],
[f"{month_index:02d}"],
[normalize_month_label(report_month)],
],
"value_candidates": month_value_variants,
"require_worksheet_match": False,
},
]
......
......@@ -41,6 +41,7 @@ CAPTURE_EXECUTION_ORDER = [
# 1) Overview (after applying filters) -> download image
# 2) StoreSalesinDetail -> download image
"overview",
"overview_s02_table_single",
"store_sales_detail",
# S03 dependencies keep their natural order afterwards.
"overview_s03",
......@@ -139,9 +140,11 @@ def resolve_month_values(report_month: str, month_mode: str) -> list[str]:
def build_specs(report_month: str, report_year: int, compare_year: int, month_mode: str) -> dict[str, Any]:
"""Build the Tableau capture and crop specs for monthly sales slides."""
# S02/S03 monthly sales always use cumulative months from January to the report month.
cumulative_month_value_candidates = build_month_value_variants(report_month, cumulative=True)
s03_month_values = build_cumulative_month_values(report_month)
report_month = normalize_month_label(report_month)
use_cumulative_months = month_mode != "single"
month_value_candidates = build_month_value_variants(report_month, cumulative=use_cumulative_months)
single_month_value_candidates = build_month_value_variants(report_month, cumulative=False)
s03_month_values = resolve_month_values(report_month, month_mode)
summary_strip_crop = {"left": 0, "top": 528, "width": 1383, "height": 332}
common_monthly_filters = [
{
......@@ -154,7 +157,7 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
{
"label": "month",
"field_candidates": ["Month", "月(daily)"],
"value_candidates": cumulative_month_value_candidates,
"value_candidates": month_value_candidates,
"apply_all_fields": True,
"target_scope": "active_sheet",
},
......@@ -168,9 +171,21 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
{"field": "Sales Type", "values": ["GMV"], "target_scope": "active_sheet"},
{"field": "Brand", "values": ["CK"], "target_scope": "active_sheet"},
]
common_single_monthly_filters = [
{
**item,
"value_candidates": single_month_value_candidates,
}
if item.get("label") == "month"
else item
for item in common_monthly_filters
]
# S02 overview needs both current-year and comparison-year measures, so it must not
# apply a hard Year filter that would exclude year2-backed values like Sales2/LFL.
s02_overview_filters = [item for item in common_monthly_filters if item.get("label") != "year"]
s02_overview_single_filters = [
item for item in common_single_monthly_filters if item.get("label") != "year"
]
return {
"captures": [
{
......@@ -184,6 +199,17 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
"raw_screenshot_name": "monthly-sales-overview.png",
"note": "S02 overview dashboard export.",
},
{
"capture_id": "overview_s02_table_single",
"session": SESSION_NAME,
"hash_url": "#/views/ECMonthlySalesReport_17250004228820/Overview?:iid=2",
"inner_frame_fragment": "/views/ECMonthlySalesReport_17250004228820/Overview?",
"activate_sheet": "Overview",
"filters": [*s02_overview_single_filters],
"params": {},
"raw_screenshot_name": "monthly-sales-overview-single.png",
"note": "S02 current-month overview table export.",
},
{
"capture_id": "overview_s03",
"session": SESSION_NAME,
......@@ -224,8 +250,8 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
{"field": "Sales Type", "values": ["GMV"]},
{"field": "Storename", "values": ["CKC-VIP"]},
{"field": "Year", "values": [str(report_year)]},
{"field": "Month", "values": [report_month]},
{"field": "月(daily)", "values": [report_month]},
{"field": "Month", "values": s03_month_values},
{"field": "月(daily)", "values": s03_month_values},
],
"params": {"year1": report_year, "year2": compare_year},
"raw_screenshot_name": "monthly-sales-store-kpi-lfl.png",
......@@ -251,7 +277,7 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
"shape_name": "鍥剧墖 6",
"shape_id": 7,
"asset_name": "s02_monthly_sales_overview_table",
"capture_id": "overview",
"capture_id": "overview_s02_table_single",
"crop": {"left": 55, "top": 140, "width": 1330, "height": 311},
"resize_to": {"width": 1097, "height": 278},
"source_view": "Overview",
......@@ -283,22 +309,22 @@ def build_specs(report_month: str, report_year: int, compare_year: int, month_mo
"panels": [
{
"panel_code": "gp",
"crop": {"left": 0, "top": 1610, "width": 650, "height": 400},
"crop": {"left": 0, "top": 1610, "width": 700, "height": 400},
"dest": {"left": 0, "top": 0, "width": 542, "height": 297},
},
{
"panel_code": "con",
"crop": {"left": 650, "top": 1610, "width": 650, "height": 400},
"crop": {"left": 700, "top": 1610, "width": 700, "height": 400},
"dest": {"left": 568, "top": 0, "width": 542, "height": 297},
},
{
"panel_code": "atv",
"crop": {"left": 0, "top": 2000, "width": 650, "height": 360},
"crop": {"left": 0, "top": 2000, "width": 700, "height": 360},
"dest": {"left": 0, "top": 324, "width": 542, "height": 297},
},
{
"panel_code": "returnqty",
"crop": {"left": 650, "top": 2000, "width": 650, "height": 360},
"crop": {"left": 700, "top": 2000, "width": 700, "height": 360},
"dest": {"left": 568, "top": 324, "width": 542, "height": 297},
},
],
......
......@@ -2440,10 +2440,15 @@ def build_product_assets(
operations: list[dict[str, Any]] = []
manifest_items: list[dict[str, Any]] = []
for slot, row in zip(slide_spec["product_slots"], source_payload["rows"]):
# Match business layout: left column TOP1-5 from top to bottom, right column TOP6-10.
slots_by_rank = {int(slot["rank"]): slot for slot in slide_spec["product_slots"]}
for rank, row in enumerate(source_payload["rows"], start=1):
slot = slots_by_rank.get(rank)
if slot is None:
continue
articlecode = row["articlecode"]
image_url = row.get("image_url") or PRODUCT_IMAGE_URL.format(articlecode=articlecode)
asset_name = f"{slide_code.lower()}_top_product_{slot['rank']:02d}_{sanitize_file_token(articlecode)}"
asset_name = f"{slide_code.lower()}_top_product_{rank:02d}_{sanitize_file_token(articlecode)}"
asset_path = asset_dir / f"{asset_name}.png"
render_product_image(
download_image(image_url),
......
......@@ -151,9 +151,19 @@ def build_download_js(spec: dict[str, Any]) -> str:
const frame = page.frames().find((f) => f.url().includes('vlp-admin.vip.com/vis/#/vis/datas/realTimeSells'));
const ctx = frame || page;
const startInput = ctx.locator('input[placeholder=\"\\u5f00\\u59cb\\u65e5\\u671f\"]').first();
const endInput = ctx.locator('input[placeholder=\"\\u7ed3\\u675f\\u65e5\\u671f\"]').first();
if (!(await startInput.count().catch(() => 0)) || !(await endInput.count().catch(() => 0))) {{
let dateSetMode = 'placeholder-inputs';
let startInput = ctx.locator('input[placeholder=\"\\u5f00\\u59cb\\u65e5\\u671f\"]').first();
let endInput = ctx.locator('input[placeholder=\"\\u7ed3\\u675f\\u65e5\\u671f\"]').first();
let startCount = await startInput.count().catch(() => 0);
let endCount = await endInput.count().catch(() => 0);
if (!startCount || !endCount) {{
dateSetMode = 'range-editor-inputs';
startInput = ctx.locator('.el-date-editor--daterange input.el-range-input, .el-range-editor input.el-range-input').nth(0);
endInput = ctx.locator('.el-date-editor--daterange input.el-range-input, .el-range-editor input.el-range-input').nth(1);
startCount = await startInput.count().catch(() => 0);
endCount = await endInput.count().catch(() => 0);
}}
if (!startCount || !endCount) {{
return {{ ok: false, code: 'DATE_INPUT_NOT_FOUND', ym: spec.ym, url: page.url(), frame: frame ? frame.url() : null }};
}}
......@@ -250,6 +260,7 @@ def build_download_js(spec: dict[str, Any]) -> str:
ok: true,
ym: spec.ym,
prefix: spec.prefix,
dateSetMode,
setDateResult,
queryClick,
downloadClick,
......
......@@ -507,7 +507,7 @@ def main() -> None:
result = json.loads(json_line)
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
validate_image(out_path, min_w=700, min_h=250, min_bytes=10000)
validate_image(out_path, min_w=560, min_h=250, min_bytes=10000)
print(json.dumps(result, ensure_ascii=False))
......
......@@ -448,7 +448,7 @@ def main() -> None:
result = json.loads(json_line)
if not result.get("ok", False):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
validate_image(out_path, min_w=760, min_h=240, min_bytes=8000)
validate_image(out_path, min_w=680, min_h=240, min_bytes=8000)
last_error = None
break
except Exception as e:
......
......@@ -373,7 +373,7 @@ def main() -> None:
str(result.get("savedAs", "")),
]
if args.config_source == "yaml":
loader_args.extend(["--config", str(config_path.resolve())])
loader_args.extend(["--config-source", "yaml", "--config", str(config_path.resolve())])
else:
loader_args.extend(
[
......
......@@ -192,7 +192,7 @@ def build_filter_config(
{
"caption": "Month",
"action": "replace",
"values": [report_month_label, next_month_label(report_month_number)],
"values": [report_month_label],
},
{"caption": "Week", "action": "all"},
{
......
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