Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in
Toggle navigation
S
skills
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
allen.wang
skills
Commits
52222f72
Commit
52222f72
authored
Jun 12, 2026
by
常佳涛
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update VIP report automation and PPT
parent
4b9160b1
Hide whitespace changes
Inline
Side-by-side
Showing
32 changed files
with
4101 additions
and
199 deletions
+4101
-199
.gitignore
.gitignore
+8
-0
Report.pptx
vip-report/Report.pptx
+0
-0
vip-report-render.ps1
vip-report/bin/vip-report-render.ps1
+64
-1
vip-report-tableau-sync.ps1
vip-report/bin/vip-report-tableau-sync.ps1
+5
-1
vip-report-uv-lfl-compose.ps1
vip-report/bin/vip-report-uv-lfl-compose.ps1
+23
-11
config.yaml
vip-report/config.yaml
+4
-4
requirements.txt
vip-report/requirements.txt
+1
-0
build_may_auto_text_ops.py
vip-report/scripts/build_may_auto_text_ops.py
+150
-0
build_slide12_editable_ops.py
vip-report/scripts/build_slide12_editable_ops.py
+266
-0
build_slide14_15_editable_ops.py
vip-report/scripts/build_slide14_15_editable_ops.py
+1020
-0
build_slide16_otd_ops.py
vip-report/scripts/build_slide16_otd_ops.py
+316
-0
download_compass_task_file.py
vip-report/scripts/download_compass_task_file.py
+130
-0
load_compass_rejection_goods_cn_to_mysql.py
...eport/scripts/load_compass_rejection_goods_cn_to_mysql.py
+3
-2
render_template_com.ps1
vip-report/scripts/render_template_com.ps1
+673
-29
render_template_openxml.py
vip-report/scripts/render_template_openxml.py
+930
-0
run_flow12_traffic_pipeline.py
vip-report/scripts/run_flow12_traffic_pipeline.py
+5
-15
run_flow14_livestream_pipeline.py
vip-report/scripts/run_flow14_livestream_pipeline.py
+8
-14
run_flow15_live_goods_effect_pipeline.py
vip-report/scripts/run_flow15_live_goods_effect_pipeline.py
+83
-30
run_flow17_membership_pipeline.py
vip-report/scripts/run_flow17_membership_pipeline.py
+1
-1
sync_cep_live_goods_effect_summary_assets.py
...port/scripts/sync_cep_live_goods_effect_summary_assets.py
+3
-8
sync_cep_live_session_assets.py
vip-report/scripts/sync_cep_live_session_assets.py
+5
-2
sync_compass_rejection_goods_download.py
vip-report/scripts/sync_compass_rejection_goods_download.py
+100
-45
sync_compass_rejection_goods_style_download.py
...rt/scripts/sync_compass_rejection_goods_style_download.py
+200
-0
sync_compass_traffic_source_download.py
vip-report/scripts/sync_compass_traffic_source_download.py
+38
-9
sync_inventory_monthly_assets.py
vip-report/scripts/sync_inventory_monthly_assets.py
+3
-7
sync_monthly_sales_assets.py
vip-report/scripts/sync_monthly_sales_assets.py
+37
-11
sync_top_products_assets.py
vip-report/scripts/sync_top_products_assets.py
+7
-2
sync_vis_live_session_downloads.py
vip-report/scripts/sync_vis_live_session_downloads.py
+14
-3
sync_vis_member_channel_trend_capture.py
vip-report/scripts/sync_vis_member_channel_trend_capture.py
+1
-1
sync_vis_member_consumption_capture.py
vip-report/scripts/sync_vis_member_consumption_capture.py
+1
-1
sync_vis_vendor_goods_effect_download.py
vip-report/scripts/sync_vis_vendor_goods_effect_download.py
+1
-1
sync_warehouse_100060_assets.py
vip-report/scripts/sync_warehouse_100060_assets.py
+1
-1
No files found.
.gitignore
View file @
52222f72
...
...
@@ -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*
vip-report/Report.pptx
View file @
52222f72
No preview for this file type
vip-report/bin/vip-report-render.ps1
View file @
52222f72
...
...
@@ -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
}
}
vip-report/bin/vip-report-tableau-sync.ps1
View file @
52222f72
...
...
@@ -7,7 +7,7 @@ param(
[
int
]
$ReportYear
=
0,
[
int
]
$CompareYear
=
0,
[
ValidateSet
(
"single"
,
"cumulative"
)]
[
string
]
$MonthlySalesMode
=
"
singl
e"
,
[
string
]
$MonthlySalesMode
=
"
cumulativ
e"
,
[
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
...
...
vip-report/bin/vip-report-uv-lfl-compose.ps1
View file @
52222f72
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
vip-report/config.yaml
View file @
52222f72
...
...
@@ -4,10 +4,10 @@ tableau:
password
:
"
1!Cu&euRo17b6yu@R40gr7"
mysql
:
host
:
"
1
92.168.138.182
"
host
:
"
1
27.0.0.1
"
port
:
3306
username
:
"
ceptes
t"
password
:
"
ck@123456
"
username
:
"
roo
t"
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
...
...
vip-report/requirements.txt
View file @
52222f72
...
...
@@ -4,3 +4,4 @@ PyYAML
lxml
matplotlib
numpy
openpyxl
vip-report/scripts/build_may_auto_text_ops.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/build_slide12_editable_ops.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/build_slide14_15_editable_ops.py
0 → 100644
View file @
52222f72
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\u54c1
spuID"
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
()
vip-report/scripts/build_slide16_otd_ops.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/download_compass_task_file.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/load_compass_rejection_goods_cn_to_mysql.py
View file @
52222f72
...
...
@@ -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"
]
...
...
vip-report/scripts/render_template_com.ps1
View file @
52222f72
...
...
@@ -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
{
}
}
}
vip-report/scripts/render_template_openxml.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/run_flow12_traffic_pipeline.py
View file @
52222f72
...
...
@@ -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"
)
...
...
vip-report/scripts/run_flow14_livestream_pipeline.py
View file @
52222f72
...
...
@@ -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
()
vip-report/scripts/run_flow15_live_goods_effect_pipeline.py
View file @
52222f72
...
...
@@ -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
,
"--s
ummary-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"
),
"--s
lides
"
,
"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
()
vip-report/scripts/run_flow17_membership_pipeline.py
View file @
52222f72
...
...
@@ -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
,
...
...
vip-report/scripts/sync_cep_live_goods_effect_summary_assets.py
View file @
52222f72
...
...
@@ -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
,
...
...
vip-report/scripts/sync_cep_live_session_assets.py
View file @
52222f72
...
...
@@ -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"
...
...
vip-report/scripts/sync_compass_rejection_goods_download.py
View file @
52222f72
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(
6
00);
await sleep(
10
00);
// 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__"
:
...
...
vip-report/scripts/sync_compass_rejection_goods_style_download.py
0 → 100644
View file @
52222f72
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
()
vip-report/scripts/sync_compass_traffic_source_download.py
View file @
52222f72
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
=
Fals
e
))
print
(
json
.
dumps
(
result
,
ensure_ascii
=
Fals
e
))
raise
RuntimeError
(
json
.
dumps
(
result
,
ensure_ascii
=
Tru
e
))
print
(
json
.
dumps
(
result
,
ensure_ascii
=
Tru
e
))
if
__name__
==
"__main__"
:
...
...
vip-report/scripts/sync_inventory_monthly_assets.py
View file @
52222f72
...
...
@@ -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
,
},
]
...
...
vip-report/scripts/sync_monthly_sales_assets.py
View file @
52222f72
...
...
@@ -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"
:
65
0
,
"height"
:
400
},
"crop"
:
{
"left"
:
0
,
"top"
:
1610
,
"width"
:
70
0
,
"height"
:
400
},
"dest"
:
{
"left"
:
0
,
"top"
:
0
,
"width"
:
542
,
"height"
:
297
},
},
{
"panel_code"
:
"con"
,
"crop"
:
{
"left"
:
650
,
"top"
:
1610
,
"width"
:
65
0
,
"height"
:
400
},
"crop"
:
{
"left"
:
700
,
"top"
:
1610
,
"width"
:
70
0
,
"height"
:
400
},
"dest"
:
{
"left"
:
568
,
"top"
:
0
,
"width"
:
542
,
"height"
:
297
},
},
{
"panel_code"
:
"atv"
,
"crop"
:
{
"left"
:
0
,
"top"
:
2000
,
"width"
:
65
0
,
"height"
:
360
},
"crop"
:
{
"left"
:
0
,
"top"
:
2000
,
"width"
:
70
0
,
"height"
:
360
},
"dest"
:
{
"left"
:
0
,
"top"
:
324
,
"width"
:
542
,
"height"
:
297
},
},
{
"panel_code"
:
"returnqty"
,
"crop"
:
{
"left"
:
650
,
"top"
:
2000
,
"width"
:
65
0
,
"height"
:
360
},
"crop"
:
{
"left"
:
700
,
"top"
:
2000
,
"width"
:
70
0
,
"height"
:
360
},
"dest"
:
{
"left"
:
568
,
"top"
:
324
,
"width"
:
542
,
"height"
:
297
},
},
],
...
...
vip-report/scripts/sync_top_products_assets.py
View file @
52222f72
...
...
@@ -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
),
...
...
vip-report/scripts/sync_vis_live_session_downloads.py
View file @
52222f72
...
...
@@ -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,
...
...
vip-report/scripts/sync_vis_member_channel_trend_capture.py
View file @
52222f72
...
...
@@ -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
=
70
0
,
min_h
=
250
,
min_bytes
=
10000
)
validate_image
(
out_path
,
min_w
=
56
0
,
min_h
=
250
,
min_bytes
=
10000
)
print
(
json
.
dumps
(
result
,
ensure_ascii
=
False
))
...
...
vip-report/scripts/sync_vis_member_consumption_capture.py
View file @
52222f72
...
...
@@ -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
=
76
0
,
min_h
=
240
,
min_bytes
=
8000
)
validate_image
(
out_path
,
min_w
=
68
0
,
min_h
=
240
,
min_bytes
=
8000
)
last_error
=
None
break
except
Exception
as
e
:
...
...
vip-report/scripts/sync_vis_vendor_goods_effect_download.py
View file @
52222f72
...
...
@@ -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
(
[
...
...
vip-report/scripts/sync_warehouse_100060_assets.py
View file @
52222f72
...
...
@@ -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"
},
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment