@1aboveio/skills 1.0.22 → 1.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +155 -136
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +76 -156
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +37 -170
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +32 -15
- package/skills/risk-compliance/portfolio-payment-analysis/references/workflow-and-contracts.md +71 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +10 -1
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.css +1 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.js +60 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/tabs.js +2 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +28 -137
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/echarts_report.py +43 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/interactive_report.py +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/issuer_data.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/payment_amounts.py +20 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +6 -157
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_base.py +164 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_custom.py +30 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_data.py +39 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_model.py +45 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_dual.py +54 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_detail.py +27 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_opened_disputes.py +30 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/LICENSE +222 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/NOTICE +5 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts-provenance.json +6 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts.min.js +45 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -6,6 +6,7 @@ Portfolio fields remain calculation/reconciliation evidence; this report never r
|
|
|
6
6
|
Edit presentation.json for narrative review, then rerender with render.py.
|
|
7
7
|
"""
|
|
8
8
|
import argparse
|
|
9
|
+
import csv
|
|
9
10
|
from csv_export import safe_writer
|
|
10
11
|
import json
|
|
11
12
|
from datetime import date
|
|
@@ -45,49 +46,6 @@ def table(headers, rows, **extra):
|
|
|
45
46
|
return {'type':'table','headers':headers,'rows':rows,**extra}
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def top_reasons(merchant):
|
|
49
|
-
return sorted(merchant.get('decline_reasons',[]),key=lambda r:(-r['count'],r['code'],r['message']))[:5]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def weekly_blocks(facts, merchants, selected):
|
|
53
|
-
series={entry.get('id'):entry for entry in facts.get('merchant_weeks',[])}
|
|
54
|
-
blocks=[]
|
|
55
|
-
if not series:
|
|
56
|
-
return blocks+[para('尚未生成子商户周数据,请先运行最新计算脚本。','callout')]
|
|
57
|
-
grid=next(iter(series.values()))['weeks']
|
|
58
|
-
def label(week):
|
|
59
|
-
return week['start']+' 至 '+week['end']+('(不完整周)' if week['partial_week'] else '(完整周)')
|
|
60
|
-
rows=[]
|
|
61
|
-
for merchant in merchants:
|
|
62
|
-
weeks=series.get(merchant.get('id'),{}).get('weeks',[])
|
|
63
|
-
cells=[]
|
|
64
|
-
for week in weeks:
|
|
65
|
-
metrics=week['metrics']
|
|
66
|
-
if not metrics.get('layer_observed',{}).get('auth',False):
|
|
67
|
-
cells.append('')
|
|
68
|
-
else:
|
|
69
|
-
successful_usd=amount(metrics,'approved_amount')
|
|
70
|
-
cells.append(number(metrics['approvals'])+' / '+number(metrics['attempts'])+' · '+percent(metrics['auth_rate'])+(' · 授权成功 USD '+successful_usd if successful_usd else ''))
|
|
71
|
-
rows.append([name(merchant),merchant.get('id') or '未归属',*cells])
|
|
72
|
-
blocks.append(para('周汇总格里的美元是授权成功金额,不是销售金额。结算层未观察到时,销售金额留空,授权成功金额仍可出现。','note'))
|
|
73
|
-
blocks.append(table(['子商户','商户 ID',*[label(w) for w in grid]],rows,wrap_headers=True))
|
|
74
|
-
chosen={m.get('id') for m in selected}
|
|
75
|
-
for merchant in merchants:
|
|
76
|
-
weeks=series.get(merchant.get('id'),{}).get('weeks',[])
|
|
77
|
-
metric_labels=['授权尝试','授权成功笔数','授权成功率','授权成功金额 USD','较上周(百分点)','尝试笔数周环比','销售金额 USD','销售金额周环比','退款笔数','退款金额 USD','争议快照记录']
|
|
78
|
-
weekly_columns=[]
|
|
79
|
-
for week in weeks:
|
|
80
|
-
metric=week['metrics'];comparison=week['comparison'];observed=metric.get('layer_observed',{}).get('auth',False)
|
|
81
|
-
pp=comparison.get('auth_rate_pp')
|
|
82
|
-
weekly_columns.append([number(metric['attempts']) if observed else '',number(metric['approvals']) if observed else '',percent(metric['auth_rate']),amount(metric,'approved_amount'),('' if pp is None else f'{pp:+.2f}'),percent(comparison.get('attempts_pct')),amount(metric,'sales'),percent(comparison.get('sales_usd_pct')),number(metric['refund_count']) if metric['layer_observed']['settlement'] else '',amount(metric,'refunds',True),number(metric['dispute_snapshots']) if metric['layer_observed']['dispute'] else ''])
|
|
83
|
-
weekly_rows=[[metric_label,*[column[i] for column in weekly_columns]] for i,metric_label in enumerate(metric_labels)]
|
|
84
|
-
blocks.append(table(['指标',*[label(w) for w in weeks]],weekly_rows,wrap_headers=True,collapsed=merchant.get('id') not in chosen,caption=name(merchant)+' · '+str(merchant.get('id') or '未归属')+' · 每周明细'))
|
|
85
|
-
if merchant.get('id') in chosen:
|
|
86
|
-
# Visible tables need a merchant heading; collapsed ones have a summary caption.
|
|
87
|
-
blocks.insert(len(blocks)-1,para(name(merchant)+' · '+str(merchant.get('id') or '未归属')+' · 每周明细','heading'))
|
|
88
|
-
return blocks
|
|
89
|
-
|
|
90
|
-
|
|
91
49
|
def write_weekly_csv(facts, output):
|
|
92
50
|
with (output/'merchant-weekly.csv').open('w',encoding='utf-8',newline='') as handle:
|
|
93
51
|
writer=safe_writer(handle,numeric_columns={11,13,15})
|
|
@@ -134,10 +92,6 @@ def segment_charts(merchant, periods):
|
|
|
134
92
|
return blocks
|
|
135
93
|
|
|
136
94
|
|
|
137
|
-
def merchant_detail_blocks(merchant, periods):
|
|
138
|
-
return segment_charts(merchant,periods)
|
|
139
|
-
|
|
140
|
-
|
|
141
95
|
def risk_charts(history):
|
|
142
96
|
if not history:
|
|
143
97
|
return []
|
|
@@ -174,16 +128,17 @@ def write_risk_csv(facts, output):
|
|
|
174
128
|
with (output/'merchant-risk-history.csv').open('w',encoding='utf-8-sig',newline='') as handle:
|
|
175
129
|
writer=safe_writer(handle,numeric_columns={7,10,11})
|
|
176
130
|
writer.writerow(['merchant_id','merchant_name','metric','frequency','start','end','series','value','date_basis','status','numerator','denominator','denominator_start','denominator_end','formula','reason'])
|
|
131
|
+
import report_model, sys
|
|
177
132
|
for merchant in facts.get('merchant_risk_history',[]):
|
|
178
|
-
for
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
133
|
+
for chart in report_model.risk_charts(facts,merchant,sys.modules[__name__]):
|
|
134
|
+
metric='vamp' if 'VAMP' in chart['title'] else 'fraud' if '欺诈报告' in chart['title'] else 'disputes'
|
|
135
|
+
frequency='weekly' if '周度' in chart['title'] else 'monthly'
|
|
136
|
+
for point in chart['points']:
|
|
137
|
+
for series,value in point['counts'].items():
|
|
138
|
+
writer.writerow([merchant.get('id'),merchant.get('name'),metric,frequency,point['start'],point['end'],series,value,chart.get('date_basis',''),'extract_observation','','','','','',''])
|
|
139
|
+
for series,value in point['rates'].items():
|
|
140
|
+
detail=point['details'].get(series,{})
|
|
141
|
+
writer.writerow([merchant.get('id'),merchant.get('name'),metric+'_rate',frequency,point['start'],point['end'],series,value,detail.get('date_basis',chart.get('date_basis','')),detail.get('status',''),detail.get('numerator'),detail.get('denominator'),detail.get('denominator_start'),detail.get('denominator_end'),detail.get('formula'),detail.get('reason')])
|
|
187
142
|
|
|
188
143
|
|
|
189
144
|
def write_segment_csv(facts, output):
|
|
@@ -203,7 +158,8 @@ def write_detail_csv(facts, output):
|
|
|
203
158
|
with (output/'merchant-detail-comparison.csv').open('w',encoding='utf-8',newline='') as handle:
|
|
204
159
|
writer=safe_writer(handle,numeric_columns={13,14})
|
|
205
160
|
writer.writerow(['merchant_id','merchant_name','dimension','population','group_key','group_label','period','start','end','count','approvals','auth_rate','share','attempt_amount_usd','approved_amount_usd'])
|
|
206
|
-
|
|
161
|
+
comparison=facts.get('interactive_report') or facts.get('merchant_detail_comparison',{})
|
|
162
|
+
for merchant in comparison.get('merchants',[]):
|
|
207
163
|
for dimension in merchant['dimensions']:
|
|
208
164
|
for group in dimension['groups']:
|
|
209
165
|
for period in group['periods']:
|
|
@@ -223,6 +179,12 @@ def without_merchants(facts, excluded):
|
|
|
223
179
|
out['merchant_breakdowns']=[m for m in facts.get('merchant_breakdowns',[]) if keep(m)]
|
|
224
180
|
detail=facts.get('merchant_detail_comparison') or {}
|
|
225
181
|
out['merchant_detail_comparison']={**detail,'merchants':[m for m in detail.get('merchants',[]) if keep(m)]}
|
|
182
|
+
interactive=facts.get('interactive_report')
|
|
183
|
+
if interactive:
|
|
184
|
+
out['interactive_report']={**interactive,'merchants':[m for m in interactive.get('merchants',[]) if keep(m)],'risk_sales':[r for r in interactive.get('risk_sales',[]) if r.get('id') not in excluded]}
|
|
185
|
+
opened=interactive.get('opened_disputes')
|
|
186
|
+
if opened is not None and 'merchant_month_counts' in opened:
|
|
187
|
+
out['interactive_report']['opened_disputes']={**opened,'merchant_month_counts':[r for r in opened['merchant_month_counts'] if r['merchant_id'] not in excluded]}
|
|
226
188
|
return out
|
|
227
189
|
|
|
228
190
|
|
|
@@ -235,14 +197,16 @@ def filter_existing_csv(path, excluded):
|
|
|
235
197
|
return
|
|
236
198
|
header,body=rows[0],rows[1:]
|
|
237
199
|
id_at=next((i for i,cell in enumerate(header) if cell in ('merchant_id','id')),None)
|
|
238
|
-
|
|
200
|
+
if id_at is None:
|
|
201
|
+
raise ValueError('Business CSV is missing a merchant ID column: '+path.name)
|
|
202
|
+
kept=[row for row in body if (row[id_at] if id_at<len(row) else None) not in excluded]
|
|
239
203
|
with path.open('w',newline='',encoding='utf-8') as handle:
|
|
240
204
|
csv.writer(handle).writerows([header,*kept])
|
|
241
205
|
|
|
242
206
|
|
|
243
207
|
def build(facts, detail_merchants=None, excluded_ids=None):
|
|
244
208
|
excluded=set(excluded_ids or [])
|
|
245
|
-
known={m.get('id') for m in facts.get('merchants',[])}
|
|
209
|
+
known={m.get('id') for m in facts.get('merchants',[])} | {m.get('id') for m in facts.get('interactive_report',{}).get('merchants',[])}
|
|
246
210
|
unknown=excluded-known
|
|
247
211
|
if unknown:
|
|
248
212
|
raise ValueError('Unknown excluded merchant IDs: '+', '.join(sorted(unknown)))
|
|
@@ -283,84 +247,11 @@ def build(facts, detail_merchants=None, excluded_ids=None):
|
|
|
283
247
|
'summary_title':'子商户核查重点','summary':findings,'sections':[],
|
|
284
248
|
'links':[{'label':'商户风险历史 CSV','file':'merchant-risk-history.csv'},{'label':'本周与上周同期细分 CSV','file':'merchant-detail-comparison.csv'},{'label':'子商户每周指标 CSV','file':'merchant-weekly.csv'},{'label':'子商户指标 CSV','file':'merchant-metrics.csv'},{'label':'商户归属 CSV','file':'merchant-mapping.csv'},{'label':'归属与核验记录 JSON','file':'coverage-and-reconciliation.json'},{'label':'Markdown 报告','file':'overview.zh.md'}],
|
|
285
249
|
'footer':'来源:Worldpay 授权、结算、争议及 DART-340 欺诈文件。'}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
observed=m.get('layer_observed',{}).get('auth',False)
|
|
292
|
-
comparison.append([name(m),m.get('id') or '未归属',number(m['attempts']) if observed else '',number(m['approvals']) if observed else '',percent(m['auth_rate']),amount(m,'sales'),number(m['refund_count']),amount(m,'refunds',True),number(m['dispute_snapshots']),status])
|
|
293
|
-
sections.append({'title':'1. 子商户对比','nav':'子商户对比','blocks':[
|
|
294
|
-
table(['子商户','商户 ID','授权尝试','授权成功','授权成功率','销售金额 USD','退款笔数','退款金额 USD','争议快照记录','样本说明'],comparison,filter=True),
|
|
295
|
-
]})
|
|
296
|
-
sections.append({'title':'2. 子商户逐周对比','nav':'逐周对比','blocks':weekly_blocks(facts,merchants,selected)})
|
|
297
|
-
detail_groups=[]
|
|
298
|
-
comparison_data=facts.get('merchant_detail_comparison',{})
|
|
299
|
-
periods=comparison_data.get('periods',[])
|
|
300
|
-
comparisons={item.get('id'):item for item in comparison_data.get('merchants',[])}
|
|
301
|
-
histories={item.get('id'):item for item in facts.get('merchant_risk_history',[])}
|
|
302
|
-
expanded={m.get('id') for m in selected}
|
|
303
|
-
for m in merchants:
|
|
304
|
-
comparison=comparisons.get(m.get('id'))
|
|
305
|
-
blocks=merchant_detail_blocks(comparison,periods) if comparison else []
|
|
306
|
-
blocks.extend(risk_charts(histories.get(m.get('id'))))
|
|
307
|
-
detail_groups.append({'type':'group','caption':name(m)+' · '+str(m.get('id') or '未归属'),'collapsed':m.get('id') not in expanded,'blocks':blocks})
|
|
308
|
-
sections.append({'title':'3. 重点子商户详情','nav':'商户详情','blocks':detail_groups})
|
|
309
|
-
action_rows=[]
|
|
310
|
-
for m in selected:
|
|
311
|
-
reasons=top_reasons(m)
|
|
312
|
-
if reasons:
|
|
313
|
-
reason=reasons[0]
|
|
314
|
-
evidence=f"{reason['message']}:{number(reason['count'])} 笔;授权成功率 {percent(m['auth_rate'])}"
|
|
315
|
-
action='核对该响应在该商户下的处理商含义,再按发卡行国家和认证状态细分检查。'
|
|
316
|
-
else:
|
|
317
|
-
evidence=f"授权成功 {number(m['approvals'])} / {number(m['attempts'])};缺少可展开的拒绝响应"
|
|
318
|
-
action='核对该商户的响应字段与来源覆盖,避免根据缺失原因直接调整策略。'
|
|
319
|
-
action_rows.append([name(m),m.get('id') or '未归属',evidence,action,'不将观察差异视为已确认原因,不估算可挽回金额。'])
|
|
320
|
-
for m in followups:
|
|
321
|
-
action_rows.append([name(m),m.get('id') or '未归属',f"未观察到本期授权;退款 {number(m['refund_count'])} 笔,争议快照记录 {number(m['dispute_snapshots'])} 条",'核对原始销售所属商户、退款处理及争议责任。','无本期授权不代表停业;争议记录不等于新增案件。'])
|
|
322
|
-
if not action_rows:
|
|
323
|
-
action_rows=[['未归属或缺少观察','','缺少支持逐商户判断的记录','先补齐商户身份及来源覆盖。','暂不形成商户表现结论。']]
|
|
324
|
-
sections.append({'title':'4. 逐商户核查清单','nav':'核查清单','blocks':[table(['子商户','商户 ID','观察依据','下一步核查','解释边界'],action_rows)]})
|
|
325
|
-
coverage=[table(['子商户','商户 ID','授权记录','结算记录','争议记录'],[[name(m),m.get('id') or '未归属',*['✓' if m.get('layer_observed',{}).get(layer,False) else '' for layer in ('auth','settlement','dispute')]] for m in merchants])]
|
|
326
|
-
audit=facts.get('quality',{}).get('risk_history',{})
|
|
327
|
-
if audit:
|
|
328
|
-
attribution=audit.get('fraud_attribution',{})
|
|
329
|
-
files=audit.get('files',[])
|
|
330
|
-
invalid=[item.get('date',item['file']) for item in files if item.get('status')=='invalid']
|
|
331
|
-
future=[item.get('date',item['file']) for item in files if item.get('status')=='future_not_parsed']
|
|
332
|
-
unmatched=attribution.get('unmatched')
|
|
333
|
-
amounts=audit.get('unmatched_amounts_by_currency') or {}
|
|
334
|
-
rows=[
|
|
335
|
-
['欺诈记录归属',str(attribution.get('attributed',0))+' / '+str(audit.get('distinct_exact_rows',0))],
|
|
336
|
-
['无效欺诈文件日期','、'.join(invalid) or '无'],
|
|
337
|
-
['数据截至',meta['main_end']]
|
|
338
|
-
]
|
|
339
|
-
if future:
|
|
340
|
-
rows.append(['晚于截止日、未解析的欺诈文件','、'.join(future)])
|
|
341
|
-
if unmatched:
|
|
342
|
-
amount_text='、'.join(currency+' '+money(value) for currency,value in amounts.items())
|
|
343
|
-
rows.append(['未归属欺诈记录',str(unmatched)+' 条'+((',金额 '+amount_text) if amount_text else '')+',未计入商户笔数'])
|
|
344
|
-
coverage.append(table(['数据范围','观察值'],rows))
|
|
345
|
-
notes=[]
|
|
346
|
-
if any(item.get('error')=='missing_header' for item in files):
|
|
347
|
-
notes.append('缺少表头的欺诈文件未解析,因此没有进入笔数。')
|
|
348
|
-
if future:
|
|
349
|
-
notes.append('晚于截止日的欺诈文件未解析。')
|
|
350
|
-
if unmatched:
|
|
351
|
-
notes.append('未能归属的欺诈记录不计入商户笔数。')
|
|
352
|
-
definitions_note=''.join(notes)
|
|
353
|
-
else:
|
|
354
|
-
definitions_note=''
|
|
355
|
-
definitions=[
|
|
356
|
-
para('空白为不可用或未观察到的数据,不作为 0;0 仅表示提供的记录中观察到的零值。'),
|
|
357
|
-
para('授权、销售与退款按各自事件日期统计。授权成功金额与结算销售金额不同,退款金额以绝对值展示。周汇总格里的美元是授权成功金额,不是销售金额。销售金额 0.00 是结算层观察到的零值;没有结算层记录时销售金额留空。'),
|
|
358
|
-
para('争议趋势按同一阶段最早观察到的争议日期统计案件,状态更新不重复计数;并非新增案件。欺诈按 340FRD 文件生成日作为报告日,完全重复记录取最早报告日。'),
|
|
359
|
-
para('比率按卡组织月度时间口径作提取数据估算,并非正式监控结果。Visa 使用当月分母,Mastercard 拒付与 EFM 使用上月分母;EFM 分子是 4837/4863 欺诈拒付,不是 340FRD 的全部 Mastercard 报告。周图仅展示笔数;预争议比率为描述性指标,不纳入 VAMP。'),
|
|
360
|
-
para('340FRD 报告日已按文件生成日确认。'+definitions_note+'VAMP 仍需核验 TC15/TC05 处理日、事件身份、适用范围及排除标记。')
|
|
361
|
-
]
|
|
362
|
-
coverage.append({'type':'group','caption':'数据口径','collapsed':True,'blocks':definitions})
|
|
363
|
-
sections.append({'title':'5. 商户归属与覆盖','nav':'归属与覆盖','blocks':coverage})
|
|
250
|
+
import report_model, sys
|
|
251
|
+
model=report_model.build_model(facts,model,sys.modules[__name__])
|
|
252
|
+
if detail_merchants:
|
|
253
|
+
tabs=model['sections'][1]['blocks'][0]['tabs']
|
|
254
|
+
tabs.sort(key=lambda tab:(tab.get('id') not in set(detail_merchants),list(detail_merchants).index(tab.get('id')) if tab.get('id') in detail_merchants else 0))
|
|
364
255
|
return model
|
|
365
256
|
|
|
366
257
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Self-contained ECharts renderer; calculations remain in presentation.json."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import render_custom as incumbent
|
|
5
|
+
base=incumbent.base
|
|
6
|
+
ROOT=Path(__file__).parent
|
|
7
|
+
|
|
8
|
+
def payload(value):return json.dumps(value,ensure_ascii=False).replace('<','\\u003c').replace('>','\\u003e').replace('&','\\u0026')
|
|
9
|
+
|
|
10
|
+
def chart_html(b,identifier,spark=False):
|
|
11
|
+
title=b.get('title','每日授权笔数')
|
|
12
|
+
data={**b,'type':'sparkline'} if spark else b
|
|
13
|
+
hp=('<div class="ec-spark"' if spark else '<div class="ec-block"')+' data-echarts-block="true">'
|
|
14
|
+
if not spark:
|
|
15
|
+
hp+='<h3>'+base.esc(title)+'</h3>'
|
|
16
|
+
note=b.get('dual_note') or b.get('description') or ''
|
|
17
|
+
if b['type']=='weekly_transaction_trend':note='柱状:授权尝试金额 USD(左轴);折线:授权尝试笔数(右轴)。最近八周内展示有数据的周。'
|
|
18
|
+
if note:hp+='<p class="note">'+base.esc(note)+'</p>'
|
|
19
|
+
if b['type']=='comparison_chart' and not b.get('approval_rate_only') and len(b['metrics'])>1:
|
|
20
|
+
hp+='<label class="ec-control">选择指标 <select data-ec-metric>'+''.join('<option value="'+base.esc(m['key'])+'"'+(' selected' if m['key']==b['default_metric'] else '')+'>'+base.esc(m['label'])+'</option>' for m in b['metrics'])+'</select></label>'
|
|
21
|
+
hp+='<div class="ec-scroll"><div class="ec-host" role="img" aria-label="'+base.esc(title)+'"></div></div><script type="application/json" data-ec-data>'+payload(data)+'</script></div>'
|
|
22
|
+
return hp
|
|
23
|
+
|
|
24
|
+
def spark(cell):
|
|
25
|
+
hp=chart_html(cell,'spark',True);points=cell['points'];desc=';'.join(p['date']+' '+(f"{p['attempts']:,} 笔" if p['attempts'] is not None else '未观察到数据') for p in points)
|
|
26
|
+
return hp+'<small class="ec-spark-dates">'+points[0]['date'][5:]+' — '+points[-1]['date'][5:]+'</small>',desc
|
|
27
|
+
|
|
28
|
+
def output(b,identifier,level=3):
|
|
29
|
+
if b['type'] in ('risk_dual_axis','weekly_transaction_trend','comparison_chart'):
|
|
30
|
+
return chart_html(b,identifier),'### '+base.mdtext(b['title'])+'\n\n'+base.mdtext(b.get('dual_note') or b.get('description') or '')
|
|
31
|
+
return incumbent.render_block(b,identifier,level,output,spark)
|
|
32
|
+
|
|
33
|
+
CSS=(ROOT/'assets/charts.css').read_text(encoding='utf-8')
|
|
34
|
+
|
|
35
|
+
JS=(ROOT/'assets/charts.js').read_text(encoding='utf-8')
|
|
36
|
+
|
|
37
|
+
TAB_JS=(ROOT/'assets/tabs.js').read_text(encoding='utf-8')
|
|
38
|
+
|
|
39
|
+
def render(model,output_dir):
|
|
40
|
+
base.render(model,output_dir,block_renderer=output,extra_css=incumbent.CSS+CSS,script=TAB_JS,comparison_js='')
|
|
41
|
+
path=Path(output_dir)/'overview.zh.html';page=path.read_text();bundle=(ROOT/'vendor/echarts.min.js').read_text().replace('</script','<\\/script')
|
|
42
|
+
notice=(ROOT/'vendor/NOTICE').read_text();license_text=(ROOT/'vendor/LICENSE').read_text();assert '-->' not in notice+license_text
|
|
43
|
+
page=page.replace('</body>','<!-- Apache ECharts 6.1.0 third-party notices\n'+notice+'\n'+license_text+'\n--><script>'+bundle+'</script><script>'+JS+'</script></body>');path.write_text(page)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Merchant-scoped issuing-bank drilldown aggregates; no raw rows are exported."""
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from payment_amounts import attempt_amount_usd
|
|
4
|
+
|
|
5
|
+
def attach(blocks,dimensions,auth,periods,calc,rates=None):
|
|
6
|
+
country=next((d for d in dimensions if d['key']=='bin_country'),None)
|
|
7
|
+
chart=next((b for b in blocks if b.get('title')==(country or {}).get('label')),None)
|
|
8
|
+
if not country or not chart:return []
|
|
9
|
+
groups={g['label']:g for g in country['groups']};audit=[]
|
|
10
|
+
current=next(p for p in periods if p['key']=='current')
|
|
11
|
+
for category in chart['categories']:
|
|
12
|
+
group=groups.get(category['label'])
|
|
13
|
+
if group is None and category.get('is_unknown'):group=next((g for g in country['groups'] if g['key']=='["unknown"]'),None)
|
|
14
|
+
if group is None:continue
|
|
15
|
+
import json
|
|
16
|
+
key=json.loads(group['key'])
|
|
17
|
+
if key[0]=='other':continue
|
|
18
|
+
rows=[r for r in auth if (r.get('Issuing bank country','').strip()==key[1] if key[0]=='value' else not r.get('Issuing bank country','').strip())]
|
|
19
|
+
ranking_rows=[r for r in rows if current['start']<=calc.day(r['Timestamp'])<=current['end']]
|
|
20
|
+
ranking=Counter(r.get('Issuing bank','').strip() or '未知发卡行' for r in ranking_rows)
|
|
21
|
+
banks=sorted((name for name,n in ranking.items() if n>0),key=lambda name:(-ranking[name],name))[:5];children=[]
|
|
22
|
+
for bank in banks:
|
|
23
|
+
values={}
|
|
24
|
+
for p in periods:
|
|
25
|
+
country_rows=[r for r in rows if p['start']<=calc.day(r['Timestamp'])<=p['end']]
|
|
26
|
+
all_rows=[r for r in auth if p['start']<=calc.day(r['Timestamp'])<=p['end']]
|
|
27
|
+
rr=[r for r in country_rows if (r.get('Issuing bank','').strip() or '未知发卡行')==bank]
|
|
28
|
+
n=len(rr);ap=sum(r.get('Response type','').lower()=='approval' for r in rr)
|
|
29
|
+
def amount(selected):
|
|
30
|
+
return attempt_amount_usd(selected,rates or {},calc.day)
|
|
31
|
+
values[p['key']]={'count':n if country_rows else None,'approvals':ap if country_rows else None,'auth_rate':ap/n if n else None,'share':n/len(all_rows) if country_rows and all_rows else None,'attempt_amount':amount(rr) if country_rows else None,'approved_amount':amount([r for r in rr if r.get('Response type','').lower()=='approval']) if country_rows else None}
|
|
32
|
+
children.append({'label':bank,'values':values,'ranking_count':ranking[bank]})
|
|
33
|
+
category['children']=children
|
|
34
|
+
audit.append({'country':category['label'],'banks':children,'country_count':len(ranking_rows),'ranking_period':{'start':current['start'],'end':current['end']},'top5_count':sum(ranking[b] for b in banks)})
|
|
35
|
+
chart['issuer_drilldown']=True
|
|
36
|
+
chart['description']='点击国家展开或折叠 Top 5 发卡行。按本周('+current['start']+' 至 '+current['end']+')授权尝试笔数降序排名(包括授权拒绝,不是按成功笔数);上周比较同一组发卡行。授权成功率显示成功笔数/尝试笔数;尝试为 0 时留空。银行比例以该商户当周全部授权尝试为分母;Top 5 不代表该国全部银行。'
|
|
37
|
+
return audit
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def calculate(dimensions,auth,periods,calc,rates=None):
|
|
41
|
+
"""Return issuer groups using source grouping keys, without a chart builder."""
|
|
42
|
+
country=next((d for d in dimensions if d['key']=='bin_country'),None)
|
|
43
|
+
if not country:return None
|
|
44
|
+
import json
|
|
45
|
+
categories=[]
|
|
46
|
+
for group in country['groups']:
|
|
47
|
+
key=json.loads(group['key'])
|
|
48
|
+
label='其他(合并)' if key==['other'] else '未知(缺失)' if key==['unknown'] else group['label']
|
|
49
|
+
categories.append({'label':label,'is_unknown':key==['unknown']})
|
|
50
|
+
block={'title':country['label'],'categories':categories}
|
|
51
|
+
attach([block],dimensions,auth,periods,calc,rates)
|
|
52
|
+
return block
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared daily-USD amount calculation for authorization aggregates."""
|
|
2
|
+
from decimal import Decimal, InvalidOperation
|
|
3
|
+
|
|
4
|
+
def attempt_amount_usd(rows, rates, day):
|
|
5
|
+
"""Return a complete decimal sum or None; never a partial converted total."""
|
|
6
|
+
total = Decimal(0)
|
|
7
|
+
for row in rows:
|
|
8
|
+
try:
|
|
9
|
+
amount = Decimal(row.get('Transaction amount', ''))
|
|
10
|
+
currency = (row.get('Transaction amount currency') or '').strip().upper()
|
|
11
|
+
rate = Decimal(1) if currency == 'USD' else rates.get((day(row['Timestamp']), currency))
|
|
12
|
+
if not amount.is_finite() or amount < 0 or rate is None:
|
|
13
|
+
return None
|
|
14
|
+
rate = Decimal(str(rate))
|
|
15
|
+
if not rate.is_finite() or rate <= 0:
|
|
16
|
+
return None
|
|
17
|
+
total += amount * rate
|
|
18
|
+
except (InvalidOperation, TypeError, KeyError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
return str(total)
|
|
@@ -1,160 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Render
|
|
3
|
-
|
|
4
|
-
Usage: python3 render.py --model presentation.json --output REPORT_DIRECTORY
|
|
5
|
-
The model owns narrative; editing it and rerendering keeps both formats in sync.
|
|
6
|
-
"""
|
|
7
|
-
import argparse
|
|
8
|
-
import html
|
|
9
|
-
import json
|
|
2
|
+
"""Render reviewed merchant presentation with embedded offline ECharts."""
|
|
3
|
+
import argparse,json
|
|
10
4
|
from pathlib import Path
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
CSS = """
|
|
14
|
-
:root{--ink:#142c36;--muted:#536770;--paper:#f4f6f4;--line:#d8e2df;--accent:#087b71;--amber:#9c5215}
|
|
15
|
-
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.7 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif}a{color:var(--accent);text-underline-offset:4px}a:focus-visible,input:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid #c16613;outline-offset:4px}.mast{background:#142c36;color:#fff;padding:42px max(24px,calc((100vw - 1180px)/2)) 36px}.eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#9bdbcf}.mast h1{font-size:clamp(28px,4vw,44px);letter-spacing:-.035em;line-height:1.25;margin:14px 0}.mast p{color:#d1e0e0;max-width:850px}.periods{display:flex;flex-wrap:wrap;gap:8px 24px;font-size:13px}.toolbar{position:sticky;top:0;z-index:2;background:#fffef9;border-bottom:1px solid var(--line);padding:12px max(24px,calc((100vw - 1180px)/2));display:flex;gap:20px;align-items:center;flex-wrap:wrap}.toolbar a{font-size:14px;text-decoration:none;min-height:30px;display:inline-flex;align-items:center}.toolbar button{margin-left:auto;background:none;border:1px solid var(--line);padding:8px 14px;border-radius:4px;color:var(--ink);cursor:pointer}main{max-width:1228px;padding:28px 24px 64px;margin:auto}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line);margin-bottom:28px}.kpi{background:#fff;padding:22px}.kpi-label{font-size:14px;color:var(--muted)}.kpi-value{font-variant-numeric:tabular-nums;font-size:clamp(25px,3vw,36px);font-weight:650;letter-spacing:-.04em;line-height:1.6}.kpi small{font-size:12px;color:var(--muted)}.summary{background:#e9f2ed;border-left:4px solid var(--accent);padding:24px 28px;margin-bottom:32px}.summary h2{margin:0 0 12px;font-size:21px}.summary ol{padding-left:22px;margin:0}.summary li+li{margin-top:10px}.section{scroll-margin-top:100px;background:white;border:1px solid var(--line);margin:24px 0;padding:28px 32px}.section h2{font-size:25px;letter-spacing:-.025em;margin:0 0 16px}.section h3{font-size:19px;margin:28px 0 12px}p{margin:10px 0 18px}.note{font-size:13px;color:var(--muted)}.callout{border-left:3px solid #b97028;background:#fff8ec;padding:14px 18px;font-size:14px}.table-wrap{overflow-x:auto;border:1px solid var(--line);margin:16px 0}table{width:100%;border-collapse:collapse;font-size:14px;font-variant-numeric:tabular-nums}th{background:#eff4f2;text-align:left;color:#35545d;font-weight:600;white-space:nowrap}th,td{padding:12px 14px;border-bottom:1px solid #e8eeeb;vertical-align:top}tbody tr:last-child td{border-bottom:0}tbody tr:hover{background:#f4f8f6}td:not(:first-child){white-space:nowrap}.filter{display:flex;align-items:center;gap:12px;margin:16px 0;font-size:14px}.filter input{width:min(320px,100%);padding:10px 12px;border:1px solid #9eb5af;border-radius:4px;font:inherit}.bars{margin:24px 0}.bar-row{display:grid;grid-template-columns:minmax(100px,180px) 1fr 100px;gap:14px;align-items:center;margin:12px 0;font-size:13px}.bar-label{overflow-wrap:anywhere}.track{background:#eff4f2;height:24px}.fill{background:var(--accent);height:100%;min-width:1px}.bar-value{text-align:right;font-variant-numeric:tabular-nums}.chart{width:100%;height:auto;display:block}.chart text{font-family:inherit;fill:#536770;font-size:12px}.chart text.title{font-size:14px}.chart line.grid{stroke:#e2eae6}.chart path.series{stroke:#087b71;stroke-width:3;fill:none}.chart circle{fill:#087b71;stroke:white;stroke-width:2}.footer{font-size:12px;color:var(--muted);padding-top:18px;border-top:1px solid var(--line)}details{margin:16px 0}summary{cursor:pointer;font-weight:600;padding:10px 0}.empty{color:var(--muted);padding:18px;background:#f4f6f4}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
16
|
-
table.wrap-headers{table-layout:fixed;min-width:900px}table.wrap-headers th,table.wrap-headers td{white-space:normal;overflow-wrap:anywhere}table.wrap-headers th:first-child{width:17%}
|
|
17
|
-
@media(max-width:700px){main{padding:18px 12px 36px}.mast{padding:28px 20px}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.kpi{padding:16px}.section{padding:22px 16px}.section h2{font-size:22px}.toolbar{position:static;padding:10px 16px;gap:10px 16px}.toolbar button{margin-left:0}.summary{padding:20px}.bar-row{grid-template-columns:110px 1fr 66px;gap:8px;font-size:12px}.periods{display:block}.periods span{display:block}}
|
|
18
|
-
@media print{body{background:white;font-size:11px}.mast{background:white;color:#142c36;padding:0}.mast p,.eyebrow{color:#536770}.toolbar,.filter{display:none}main{padding:10px 0;max-width:none}.section{break-inside:avoid;padding:16px}.kpis{grid-template-columns:repeat(4,1fr)}.kpi-value{font-size:23px}a{color:inherit}details>*{display:block!important}.table-wrap{overflow:visible}th,td{padding:7px}.summary{background:#f2f5f2}}
|
|
19
|
-
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
|
20
|
-
"""
|
|
21
|
-
JS = """
|
|
22
|
-
document.querySelectorAll('input[data-table]').forEach(input=>input.addEventListener('input',()=>{
|
|
23
|
-
const table=document.getElementById(input.dataset.table), query=input.value.toLowerCase();
|
|
24
|
-
let visible=0;
|
|
25
|
-
table.querySelectorAll('tbody tr').forEach(row=>{row.hidden=!row.textContent.toLowerCase().includes(query);if(!row.hidden)visible++});
|
|
26
|
-
document.getElementById(input.dataset.table+'-status').textContent='显示 '+visible+' 行';
|
|
27
|
-
}));
|
|
28
|
-
document.getElementById('print-report').addEventListener('click',()=>window.print());
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def esc(value):
|
|
32
|
-
return html.escape(str(value), quote=True)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def mdtext(value):
|
|
36
|
-
text=html.escape(str(value),quote=False).replace('\n',' ')
|
|
37
|
-
for char in ('\\','*','_','[',']','`','#','|'):
|
|
38
|
-
text=text.replace(char,'\\'+char)
|
|
39
|
-
return text
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def mdcell(value):
|
|
43
|
-
return mdtext(value)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def table(block, identifier):
|
|
47
|
-
headers, rows = block['headers'], block['rows']
|
|
48
|
-
body = '<div class="table-wrap"><table id="'+identifier+'"'+(' class="wrap-headers"' if block.get('wrap_headers') else '')+'><thead><tr>'
|
|
49
|
-
body += ''.join('<th scope="col">'+esc(v)+'</th>' for v in headers)+'</tr></thead><tbody>'
|
|
50
|
-
body += ''.join('<tr>'+''.join('<td>'+esc(v)+'</td>' for v in row)+'</tr>' for row in rows)
|
|
51
|
-
body += '</tbody></table></div>'
|
|
52
|
-
if block.get('filter'):
|
|
53
|
-
body = f'<label class="filter">筛选商户<input data-table="{identifier}" type="search" placeholder="商户名称或 ID" aria-controls="{identifier}"></label><span class="sr-only" id="{identifier}-status" aria-live="polite"></span>'+body
|
|
54
|
-
md = '| '+' | '.join(map(mdcell, headers))+' |\n| '+' | '.join(['---']*len(headers))+' |\n'
|
|
55
|
-
md += '\n'.join('| '+' | '.join(map(mdcell, row))+' |' for row in rows)
|
|
56
|
-
return body, md
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def bars(block):
|
|
60
|
-
rows = block['rows']
|
|
61
|
-
maximum = max((r['value'] for r in rows), default=0) or 1
|
|
62
|
-
out = '<div class="bars" role="img" aria-label="'+esc(block.get('title','数量分布'))+'">'
|
|
63
|
-
for r in rows:
|
|
64
|
-
out += '<div class="bar-row"><span class="bar-label">'+esc(r['label'])+'</span><div class="track"><div class="fill" style="width:'+str(max(0,r['value']/maximum*100))+'%"></div></div><span class="bar-value">'+esc(r.get('display',r['value']))+'</span></div>'
|
|
65
|
-
return out+'</div>', '\n'.join('- '+mdtext(r['label'])+':'+mdtext(r.get('display',r['value'])) for r in rows)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def line_chart(block):
|
|
69
|
-
rows=block['rows']; w=1000; h=250; left=52; right=30; top=32; bottom=45
|
|
70
|
-
maximum=block.get('max',100)
|
|
71
|
-
plotw=w-left-right; ploth=h-top-bottom
|
|
72
|
-
out=f'<svg class="chart" viewBox="0 0 {w} {h}" role="img" aria-label="{esc(block.get("title","趋势"))}"><title>{esc(block.get("title","趋势"))}</title>'
|
|
73
|
-
for n in range(5):
|
|
74
|
-
value=maximum*n/4; y=top+ploth*(1-n/4)
|
|
75
|
-
out+=f'<line class="grid" x1="{left}" y1="{y}" x2="{w-right}" y2="{y}"/><text x="{left-8}" y="{y+4}" text-anchor="end">{value:g}</text>'
|
|
76
|
-
points=[]
|
|
77
|
-
for i,r in enumerate(rows):
|
|
78
|
-
x=left+plotw*i/max(1,len(rows)-1)
|
|
79
|
-
if r['value'] is None:
|
|
80
|
-
if points: out+='<path class="series" d="'+' '.join(points)+'"/>';points=[]
|
|
81
|
-
else:
|
|
82
|
-
y=top+ploth*(1-r['value']/maximum)
|
|
83
|
-
points.append(('M' if not points else 'L')+f'{x},{y}')
|
|
84
|
-
out+=f'<circle cx="{x}" cy="{y}" r="4"><title>{esc(r["label"])}: {r["value"]:.2f}</title></circle>'
|
|
85
|
-
if len(rows)<=8 or i%max(1,len(rows)//8)==0 or i==len(rows)-1:
|
|
86
|
-
out+=f'<text x="{x}" y="{h-15}" text-anchor="middle">{esc(r["label"])}</text>'
|
|
87
|
-
if points: out+='<path class="series" d="'+' '.join(points)+'"/>'
|
|
88
|
-
out+='</svg>'
|
|
89
|
-
return out, '\n'.join('- '+mdtext(r['label'])+':'+('N/A' if r['value'] is None else f'{r["value"]:.2f}') for r in rows)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def block_output(block, identifier, level=3):
|
|
93
|
-
kind=block['type'];text=block.get('text','')
|
|
94
|
-
if kind=='paragraph': hp='<p>'+esc(text)+'</p>';mp=mdtext(text)
|
|
95
|
-
elif kind=='note': hp='<p class="note">'+esc(text)+'</p>';mp=mdtext(text)
|
|
96
|
-
elif kind=='callout': hp='<p class="callout">'+esc(text)+'</p>';mp='> '+mdtext(text)
|
|
97
|
-
elif kind=='heading': hp=f'<h{level}>'+esc(text)+f'</h{level}>';mp='#'*level+' '+mdtext(text)
|
|
98
|
-
elif kind=='table': hp,mp=table(block,identifier)
|
|
99
|
-
elif kind=='bars': hp,mp=bars(block)
|
|
100
|
-
elif kind=='line': hp,mp=line_chart(block)
|
|
101
|
-
elif kind=='list':
|
|
102
|
-
hp='<ol>'+''.join('<li>'+esc(x)+'</li>' for x in block['items'])+'</ol>'
|
|
103
|
-
mp='\n'.join(f'{n+1}. {mdtext(x)}' for n,x in enumerate(block['items']))
|
|
104
|
-
elif kind in ('comparison_chart','time_series_chart'): hp,mp=comparison_chart(block,identifier)
|
|
105
|
-
elif kind=='group':
|
|
106
|
-
children=[block_output(child,identifier+'-'+str(i),min(level+1,6)) for i,child in enumerate(block['blocks'])]
|
|
107
|
-
hp=''.join(pair[0] for pair in children);mp='\n\n'.join(pair[1] for pair in children)
|
|
108
|
-
if not block.get('collapsed'):
|
|
109
|
-
hp=f'<h{level}>'+esc(block['caption'])+f'</h{level}>'+hp
|
|
110
|
-
mp='#'*level+' '+mdtext(block['caption'])+'\n\n'+mp
|
|
111
|
-
else: raise ValueError('Unknown block type: '+kind)
|
|
112
|
-
if block.get('collapsed'):
|
|
113
|
-
hp='<details><summary>'+esc(block.get('caption','查看明细'))+'</summary>'+hp+'</details>'
|
|
114
|
-
mp='#'*level+' '+mdtext(block.get('caption','查看明细'))+'\n\n'+mp
|
|
115
|
-
return hp,mp
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def render(model, output):
|
|
119
|
-
output=Path(output);output.mkdir(parents=True,exist_ok=True)
|
|
120
|
-
parts=[]; md=['# '+mdtext(model['title']),mdtext(model.get('subtitle',''))]
|
|
121
|
-
periods=model.get('periods',[])
|
|
122
|
-
md.extend(map(mdtext,periods))
|
|
123
|
-
if model.get('summary'):
|
|
124
|
-
parts.append('<section class="summary"><h2>'+esc(model.get('summary_title','本期要点'))+'</h2><ol>'+''.join('<li>'+esc(x)+'</li>' for x in model['summary'])+'</ol></section>')
|
|
125
|
-
md+=['## '+mdtext(model.get('summary_title','本期要点'))]+[f'{i+1}. {mdtext(x)}' for i,x in enumerate(model['summary'])]
|
|
126
|
-
if model.get('kpis'):
|
|
127
|
-
parts.append('<div class="kpis">'+''.join('<div class="kpi"><div class="kpi-label">'+esc(k['label'])+'</div><div class="kpi-value">'+esc(k['value'])+'</div><small>'+esc(k.get('note',''))+'</small></div>' for k in model['kpis'])+'</div>')
|
|
128
|
-
md+=['## 核心指标',table({'headers':['指标','数值','说明'],'rows':[[k['label'],k['value'],k.get('note','')] for k in model['kpis']]},'kpis')[1]]
|
|
129
|
-
nav=[]
|
|
130
|
-
for i,section in enumerate(model['sections']):
|
|
131
|
-
sid='section-'+str(i+1)
|
|
132
|
-
nav.append(f'<a href="#{sid}">{esc(section.get("nav",section["title"]))}</a>')
|
|
133
|
-
parts.append(f'<section class="section" id="{sid}"><h2>{esc(section["title"])}</h2>')
|
|
134
|
-
md+=['## '+mdtext(section['title'])]
|
|
135
|
-
for j,b in enumerate(section['blocks']):
|
|
136
|
-
hp,mp=block_output(b,sid+'-table-'+str(j))
|
|
137
|
-
parts.append(hp);md.append(mp)
|
|
138
|
-
parts.append('</section>')
|
|
139
|
-
footer=model.get('footer','')
|
|
140
|
-
links=model.get('links',[])
|
|
141
|
-
# Links are local artifacts only; reject script URLs and escaping parent paths.
|
|
142
|
-
for link in links:
|
|
143
|
-
target=Path(link['file'])
|
|
144
|
-
if target.is_absolute() or len(target.parts)!=1 or ':' in link['file'] or target.name in {'.','..'}:
|
|
145
|
-
raise ValueError('Report links must name a sibling file')
|
|
146
|
-
footer_html=esc(footer)
|
|
147
|
-
if links:
|
|
148
|
-
footer_html+='<p>'+ ' · '.join('<a href="'+esc(l['file'])+'">'+esc(l['label'])+'</a>' for l in links)+'</p>'
|
|
149
|
-
md.append(' · '.join('['+l['label']+']('+l['file']+')' for l in links))
|
|
150
|
-
page='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>'+esc(model['title'])+'</title><style>'+CSS+COMPARISON_CSS+'</style></head><body><header class="mast"><div class="eyebrow">Submerchant / Payment Review</div><h1>'+esc(model['title'])+'</h1><p>'+esc(model.get('subtitle',''))+'</p><div class="periods">'+''.join('<span>'+esc(x)+'</span>' for x in periods)+'</div></header><nav class="toolbar" aria-label="报告导航">'+''.join(nav)+'<button type="button" id="print-report">打印 / 保存 PDF</button></nav><main>'+''.join(parts)+'<footer class="footer">'+footer_html+'</footer></main><script>'+JS+COMPARISON_JS+'</script></body></html>'
|
|
151
|
-
(output/'overview.zh.html').write_text(page,encoding='utf-8')
|
|
152
|
-
(output/'overview.zh.md').write_text('\n\n'.join(md+[mdtext(footer)])+'\n',encoding='utf-8')
|
|
153
|
-
|
|
154
|
-
|
|
5
|
+
from echarts_report import render
|
|
155
6
|
if __name__=='__main__':
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
args=parser.parse_args()
|
|
160
|
-
render(json.loads(args.model.read_text(encoding='utf-8')),args.output)
|
|
7
|
+
parser=argparse.ArgumentParser(description=__doc__)
|
|
8
|
+
parser.add_argument('--model',type=Path,required=True);parser.add_argument('--output',type=Path,required=True)
|
|
9
|
+
args=parser.parse_args();render(json.loads(args.model.read_text(encoding='utf-8')),args.output)
|