@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.
Files changed (92) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +155 -136
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +1 -1
  6. package/skills/app-dev/app-debug/SKILL.md +1 -1
  7. package/skills/app-dev/better-auth/SKILL.md +1 -1
  8. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  9. package/skills/app-dev/data-quality/SKILL.md +1 -1
  10. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  11. package/skills/app-dev/maestro/SKILL.md +1 -1
  12. package/skills/app-dev/monorepo/SKILL.md +1 -1
  13. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  14. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  15. package/skills/app-dev/pyspark/SKILL.md +1 -1
  16. package/skills/app-dev/python-backend/SKILL.md +1 -1
  17. package/skills/app-dev/shadcn/SKILL.md +1 -1
  18. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  19. package/skills/cicd/cloud-build/SKILL.md +1 -1
  20. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  21. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  22. package/skills/cicd/docker/SKILL.md +1 -1
  23. package/skills/cicd/github-actions/SKILL.md +1 -1
  24. package/skills/cicd/github-flow/SKILL.md +1 -1
  25. package/skills/cicd/google-cloud/SKILL.md +1 -1
  26. package/skills/cicd/mergify/SKILL.md +1 -1
  27. package/skills/cicd/podman/SKILL.md +1 -1
  28. package/skills/content/article-review/SKILL.md +1 -1
  29. package/skills/content/article-to-image/SKILL.md +1 -1
  30. package/skills/content/flowchart-svg/SKILL.md +1 -1
  31. package/skills/content/image-gen/SKILL.md +1 -1
  32. package/skills/content/mermaid/SKILL.md +1 -1
  33. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  34. package/skills/content/wechat-publisher/SKILL.md +1 -1
  35. package/skills/content/wechat-search/SKILL.md +1 -1
  36. package/skills/content/xai-search/SKILL.md +1 -1
  37. package/skills/core/distill-writing/SKILL.md +1 -1
  38. package/skills/core/doc-to-md/SKILL.md +1 -1
  39. package/skills/core/editorial-review/SKILL.md +1 -1
  40. package/skills/core/house-style/SKILL.md +1 -1
  41. package/skills/core/plain-language/SKILL.md +1 -1
  42. package/skills/core/polish-cn/SKILL.md +1 -1
  43. package/skills/core/reg-parser/SKILL.md +1 -1
  44. package/skills/core/terminology/SKILL.md +1 -1
  45. package/skills/core/write-doc/SKILL.md +1 -1
  46. package/skills/core/write-report/SKILL.md +1 -1
  47. package/skills/engineering/better-review/SKILL.md +1 -1
  48. package/skills/engineering/e2e-test/SKILL.md +1 -1
  49. package/skills/engineering/engineering/SKILL.md +1 -1
  50. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  51. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  52. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  53. package/skills/engineering/golden-path/SKILL.md +1 -1
  54. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  55. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  56. package/skills/engineering/resolve-release/SKILL.md +1 -1
  57. package/skills/engineering/rush-issues/SKILL.md +1 -1
  58. package/skills/engineering/rush-release/SKILL.md +1 -1
  59. package/skills/engineering/test-quality/SKILL.md +1 -1
  60. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  61. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  62. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  63. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  64. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  65. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  66. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +76 -156
  67. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +37 -170
  68. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +32 -15
  69. package/skills/risk-compliance/portfolio-payment-analysis/references/workflow-and-contracts.md +71 -0
  70. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +10 -1
  71. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.css +1 -0
  72. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.js +60 -0
  73. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/tabs.js +2 -0
  74. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +28 -137
  75. package/skills/risk-compliance/portfolio-payment-analysis/scripts/echarts_report.py +43 -0
  76. package/skills/risk-compliance/portfolio-payment-analysis/scripts/interactive_report.py +3 -0
  77. package/skills/risk-compliance/portfolio-payment-analysis/scripts/issuer_data.py +52 -0
  78. package/skills/risk-compliance/portfolio-payment-analysis/scripts/payment_amounts.py +20 -0
  79. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +6 -157
  80. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_base.py +164 -0
  81. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_custom.py +30 -0
  82. package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_data.py +39 -0
  83. package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_model.py +45 -0
  84. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_dual.py +54 -0
  85. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_detail.py +27 -0
  86. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_opened_disputes.py +30 -0
  87. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/LICENSE +222 -0
  88. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/NOTICE +5 -0
  89. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts-provenance.json +6 -0
  90. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts.min.js +45 -0
  91. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  92. 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 metric in ('disputes','fraud','vamp'):
179
- history=merchant[metric]
180
- for frequency in ('monthly','weekly'):
181
- for point in history.get(frequency,[]):
182
- for series,value in point['values'].items():
183
- writer.writerow([merchant.get('id'),merchant.get('name'),metric,frequency,point['start'],point['end'],series,value,history.get('basis',''),history.get('status',''),'','','','','',''])
184
- for series,value in point.get('rates',{}).items():
185
- detail=point.get('rate_details',{}).get(series,{})
186
- writer.writerow([merchant.get('id'),merchant.get('name'),metric+'_rate',frequency,point['start'],point['end'],series,value,detail.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')])
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
- for merchant in facts.get('merchant_detail_comparison',{}).get('merchants',[]):
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
- kept=[row for row in body if (row[id_at] if id_at is not None and id_at<len(row) else None) not in excluded and not any(cell in excluded for cell in row)]
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
- sections=model['sections']
287
- comparison=[]
288
- for m in merchants:
289
- status='本期有授权记录' if m['attempts'] else '本期无授权记录'
290
- if 0<m['attempts']<30:status='样本少于 30 笔'
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,3 @@
1
+ """Compatibility imports for saved integrations; implementation has explicit stages."""
2
+ from report_data import calculate
3
+ from report_model import build_model
@@ -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 a reviewed report model to portable HTML and Markdown, without dependencies.
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 comparison_charts import chart as comparison_chart, CSS as COMPARISON_CSS, JS as COMPARISON_JS
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
- parser=argparse.ArgumentParser(description=__doc__)
157
- parser.add_argument('--model',required=True,type=Path)
158
- parser.add_argument('--output',required=True,type=Path)
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)