@1aboveio/skills 1.0.21 → 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 +175 -153
- 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 +4 -2
- package/skills/cicd/github-actions/references/debugging.md +21 -0
- package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +7 -0
- package/skills/cicd/github-actions/scripts/watch-run.mjs +511 -0
- package/skills/cicd/github-flow/SKILL.md +35 -9
- package/skills/cicd/github-flow/scripts/watch-checks.mjs +561 -0
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +2 -2
- package/skills/cicd/mergify/references/diagnosis.md +6 -1
- package/skills/cicd/mergify/references/watch-contract.md +10 -7
- package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
- 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 +2 -1
- package/skills/engineering/harness-runtime/contracts.md +2 -0
- package/skills/engineering/harness-runtime/references/subagent-sessions.md +75 -0
- package/skills/engineering/resolve-issues/SKILL.md +2 -2
- package/skills/engineering/resolve-issues/references/ci.md +2 -4
- package/skills/engineering/resolve-issues/references/delivery-progress.md +1 -1
- package/skills/engineering/resolve-issues/references/done.md +3 -6
- package/skills/engineering/resolve-issues/references/expire.md +2 -2
- package/skills/engineering/resolve-issues/references/implementation.md +52 -41
- package/skills/engineering/resolve-issues/references/review.md +18 -19
- package/skills/engineering/resolve-issues/references/shared-modules.md +7 -1
- package/skills/engineering/resolve-issues/references/task-plan.md +18 -10
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +42 -32
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/resolve-release/references/publish.md +12 -4
- package/skills/engineering/resolve-release/references/workspace-npm.md +6 -4
- 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
package/skills/risk-compliance/portfolio-payment-analysis/references/workflow-and-contracts.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Workflow and module contracts
|
|
2
|
+
|
|
3
|
+
The skill serves payment portfolios whose records can be attributed to submerchants.
|
|
4
|
+
Worldpay CSV and DART-340 are the bundled source adapters. Other sources require an
|
|
5
|
+
explicit field/identity mapping into the same event layers; a similar descriptor is
|
|
6
|
+
not such a mapping. Keep source-specific interpretation in preparation, financial
|
|
7
|
+
arithmetic in calculation, report selection in construction, and browser behavior in
|
|
8
|
+
rendering.
|
|
9
|
+
|
|
10
|
+
## Stages and completion criteria
|
|
11
|
+
|
|
12
|
+
| Stage | Input → output | Complete when |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Scope | Request and authorized files → dates, identities, coverage | Main, authorization and risk periods are explicit; missing layers and identity conflicts are recorded. |
|
|
15
|
+
| Prepare | Original or selected sources → verified event layers | Source hashes and selected-row accounting reconcile; authorization, sale/refund and dispute grains are distinct; unknown identities remain unknown. |
|
|
16
|
+
| Calculate | Event layers and daily FX → `facts.json` and business CSVs | Merchant numerators, denominators, event dates, currency conversions, nulls and observed zeros are reproducible without a chart library. |
|
|
17
|
+
| Construct | Frozen facts and merchant selection → `presentation.json` | Both report sections and each selected merchant's charts reference the correct evidence and independent cutoff; exclusions apply to every business output. |
|
|
18
|
+
| Review | Saved presentation and evidence → reviewed presentation | Findings, coverage and terminology are checked; required language checks are recorded honestly, with no changes to financial facts. |
|
|
19
|
+
| Render | Reviewed presentation → HTML and Markdown companion | HTML embeds ECharts and notices, loads offline, and shows all tabs and supported chart interactions. Rendering does not load payment sources or recalculate metrics. |
|
|
20
|
+
| Verify | Saved outputs → verification record | Numeric series, missing values, cutoffs, issuer ranking, actual tooltips, tab resizing and printing have evidence; incomplete checks are named. |
|
|
21
|
+
|
|
22
|
+
## Report structure
|
|
23
|
+
|
|
24
|
+
The default compact merchant report contains findings followed by a merchant comparison
|
|
25
|
+
and merchant-detail tabs. The legacy visible headings `1. 子商户对比` and
|
|
26
|
+
`3. 重点子商户详情` are retained for compatibility; their numbering does not imply
|
|
27
|
+
hidden chapters. All verified merchants remain reachable. An initial-tab selection is
|
|
28
|
+
navigation, while exclusion changes the report population. Unknown identities stay in
|
|
29
|
+
reconciliation, outside merchant tabs.
|
|
30
|
+
|
|
31
|
+
Comparison: main-period attempts, approvals, approval rate, approved authorization USD,
|
|
32
|
+
refunds, dispute observations and daily attempt-count sparklines. Details: up to eight
|
|
33
|
+
observed authorization weeks; two-week country, product, amount, authentication and
|
|
34
|
+
response comparisons; twelve-month/eight-week risk histories. Exact chart and metric
|
|
35
|
+
contracts are in [metrics and reporting](metrics-and-reporting.md) and
|
|
36
|
+
[risk history](risk-history.md).
|
|
37
|
+
|
|
38
|
+
## Dates and missing data
|
|
39
|
+
|
|
40
|
+
Main comparison and risk history share the earliest relevant layer's final event date.
|
|
41
|
+
Authorization-only details and trends use the earlier of requested end and final
|
|
42
|
+
authorization date. Paired authorization periods match weekdays across adjacent weeks;
|
|
43
|
+
missing dates retain positions. No full period or continuous coverage is inferred from
|
|
44
|
+
minimum/maximum timestamps. A null is unavailable, not zero. A zero approval numerator
|
|
45
|
+
with positive attempts is 0%; no attempts has no defined approval rate. Missing FX
|
|
46
|
+
invalidates the affected amount, not its count.
|
|
47
|
+
|
|
48
|
+
## Script responsibilities
|
|
49
|
+
|
|
50
|
+
- `analyze.py` owns source loading, verified merchant mapping and reconciliation; `report_data.py`
|
|
51
|
+
derives reusable aggregate evidence from those event layers.
|
|
52
|
+
- `payment_amounts.py` owns shared complete-or-unavailable daily USD sums.
|
|
53
|
+
- Preparation accepts calculation functions explicitly and returns JSON-compatible
|
|
54
|
+
aggregates. It does not import the report builder or construct chart containers.
|
|
55
|
+
- `report_model.py` accepts frozen aggregate evidence, explicit risk denominators
|
|
56
|
+
and dispute counts. It does not retain mutable process-wide calculation state.
|
|
57
|
+
- `build_report.py` owns findings, selections and business exports; its model module
|
|
58
|
+
defines the report structure and chart declarations.
|
|
59
|
+
- `render.py` is the stable CLI. `render_base.py` and `render_custom.py` compose
|
|
60
|
+
the document and tabs; `echarts_report.py` creates chart declarations and embeds
|
|
61
|
+
`scripts/assets/charts.js`, `charts.css` and `tabs.js`. The HTML shell receives chart and cell rendering
|
|
62
|
+
functions explicitly. ECharts reads only chart declarations and serializes safely;
|
|
63
|
+
JavaScript chart code and styles are packaged assets, not payment calculators.
|
|
64
|
+
|
|
65
|
+
## Extension policy
|
|
66
|
+
|
|
67
|
+
Add a source adapter when a supported source needs different parsing or identity rules.
|
|
68
|
+
Add a metric only with its population, date basis, numerator, denominator, currency and
|
|
69
|
+
availability rule. Add a chart only after its declared series exist in frozen facts.
|
|
70
|
+
Do not add arbitrary report configuration for a hypothetical use case. Keep the
|
|
71
|
+
validated default report and CLI stable while internal modules become reusable.
|
|
@@ -597,7 +597,16 @@ def analyze(root, start, end, fxpath):
|
|
|
597
597
|
'timezone': 'Source timestamps are naive; timezone is unknown'}
|
|
598
598
|
merchant_risk_history, reconciliation['risk_history'] = risk_history.build(
|
|
599
599
|
root, end, comparison_merchants, layers['settlement'], layers['dispute'])
|
|
600
|
-
|
|
600
|
+
import report_data, sys
|
|
601
|
+
interactive_merchants=list(merchants)
|
|
602
|
+
for mid in all_ids:
|
|
603
|
+
if mid in ids:
|
|
604
|
+
continue
|
|
605
|
+
label=sorted(names[mid],key=lambda x:(-names[mid][x],x))[0] if names[mid] else 'Unknown'
|
|
606
|
+
interactive_merchants.append({'id':mid or None,'name':label,**summary([],[],[],rates,present)})
|
|
607
|
+
auth_end=min(requested_end,date.fromisoformat(last_dates['auth']))
|
|
608
|
+
interactive = report_data.calculate(layers, end, interactive_merchants, rates, sys.modules[__name__],auth_end=auth_end)
|
|
609
|
+
return {'schema_version': 1, 'interactive_report': interactive, 'metadata': {'main_start': start.isoformat(), 'main_end': end.isoformat(),
|
|
601
610
|
'requested_main_end': requested_end.isoformat(), 'layers_present': present, 'layer_last_dates': last_dates,
|
|
602
611
|
'comparison_start': previous_start.isoformat(), 'comparison_end': previous_end.isoformat(),
|
|
603
612
|
'auth_date': 'Timestamp', 'settlement_date': 'Settlement date', 'dispute_denominator_date': 'Timestamp (transaction/capture date)', 'fx_date': 'Timestamp', 'rate_unit': 'fraction'},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ec-block{margin:28px 0;min-width:0}.ec-block h3{margin-bottom:10px}.ec-control{display:block;margin:12px 0}.ec-control select{font:inherit;min-height:44px;padding:6px 10px}.ec-scroll{overflow-x:auto}.ec-host{width:100%;height:430px;min-width:640px}.ec-spark{width:168px}.ec-spark .ec-host{width:168px;min-width:168px;height:44px}.ec-spark-dates{font-size:12px;color:#536770}.ec-tooltip{font-variant-numeric:tabular-nums;line-height:1.7}.ec-tooltip b{font-weight:650}.ec-block .ec-host:focus-visible{outline:2px solid #c16613}.ec-tooltip-row{white-space:normal}@media(max-width:700px){.ec-host{min-width:760px}.ec-spark .ec-host{min-width:168px}.ec-block[data-ec-pie] .ec-host{min-width:550px}}@media print{.ec-control{display:none}.ec-scroll{overflow:visible}.ec-host{min-width:0!important}.ec-block{break-inside:avoid}}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
(()=>{
|
|
2
|
+
const colors=['#087b71','#7761ad','#a65721','#4f7489','#8b7135','#6e8178'];const states=[];
|
|
3
|
+
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
4
|
+
const value=v=>v===null||v===undefined||v===''?null:Number(v);
|
|
5
|
+
const fmt=(v,f='count')=>v===null||v===undefined?'—':f==='percent'?(Number(v)*100).toFixed(2)+'%':(f==='usd'?'USD ':'')+Number(v).toLocaleString('en-US',{minimumFractionDigits:f==='usd'?2:0,maximumFractionDigits:f==='usd'?2:0});
|
|
6
|
+
const tooltip={trigger:'axis',showDelay:0,hideDelay:0,transitionDuration:0,appendTo:()=>document.body,confine:false,backgroundColor:'#142c36',borderColor:'#36545b',textStyle:{color:'#fff',fontSize:13},extraCssText:'max-width:min(460px,calc(100vw - 24px));white-space:normal;line-height:1.7;z-index:10000;',axisPointer:{type:'cross'},className:'ec-tooltip'};
|
|
7
|
+
const wrap=(head,lines)=>'<b>'+esc(head)+'</b><br>'+lines.map(esc).join('<br>');
|
|
8
|
+
const base=()=>({animation:false,color:colors,aria:{enabled:true},textStyle:{fontFamily:"-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif"},legend:{type:'scroll',top:0,textStyle:{color:'#344a51'}},grid:{left:80,right:90,top:78,bottom:62},tooltip:{...tooltip},xAxis:{type:'category',axisLabel:{hideOverlap:true,color:'#536770'}},yAxis:{type:'value',min:0,axisLabel:{color:'#536770'},splitLine:{lineStyle:{color:'#e2eae6'}}}});
|
|
9
|
+
function option(state){
|
|
10
|
+
const d=state.data,o=base();state.rows=[];
|
|
11
|
+
if(d.type==='sparkline'){
|
|
12
|
+
o.legend={show:false};o.grid={left:4,right:4,top:4,bottom:4};o.xAxis={type:'category',show:false,boundaryGap:false,data:d.points.map(p=>p.date)};o.yAxis={type:'value',show:false,min:0,max:d.max||1};o.series=[{name:'每日授权笔数',type:'line',connectNulls:false,symbolSize:3,lineStyle:{width:2},data:d.points.map(p=>p.attempts)}];o.tooltip={...tooltip,axisPointer:{type:'line'},formatter:ps=>{const p=d.points[ps[0]?.dataIndex];return p?wrap(p.date,['授权尝试:'+fmt(p.attempts)+' 笔']):'';}};return o;
|
|
13
|
+
}
|
|
14
|
+
if(d.type==='risk_dual_axis'){
|
|
15
|
+
o.xAxis.data=d.points.map(p=>p.label+(p.partial?'*':''));o.yAxis=[{type:'value',name:'数量(笔)',min:0,minInterval:1,splitLine:{lineStyle:{color:'#e2eae6'}}},{type:'value',name:'比率 %',min:0,axisLabel:{formatter:v=>fmt(v,'percent')},splitLine:{show:false}}];
|
|
16
|
+
o.series=d.count_series.map((s,i)=>({name:s.label+' · 数量',type:'bar',yAxisIndex:0,barMaxWidth:26,itemStyle:{color:colors[i%colors.length],opacity:.55},data:d.points.map(p=>p.counts[s.key])})).concat(d.rate_series.map((s,i)=>({name:s.label+' · 比率',type:'line',yAxisIndex:1,connectNulls:false,symbolSize:6,itemStyle:{color:colors[i%colors.length]},data:d.points.map(p=>{const v=p.rates[s.key];return v!==null&&v>0?v:null;})})));
|
|
17
|
+
o.tooltip.formatter=ps=>{const p=d.points[ps[0]?.dataIndex];if(!p)return '';const lines=d.count_series.map(s=>s.label+':'+fmt(p.counts[s.key])+' 笔');d.rate_series.forEach(s=>{const v=p.rates[s.key],detail=p.details[s.key]||{};lines.push(s.label+':'+fmt(v,'percent')+(detail.numerator!==null&&detail.numerator!==undefined&&detail.denominator?' ('+fmt(detail.numerator)+' / '+fmt(detail.denominator)+')':''));});return wrap(p.start+' 至 '+p.end+(p.partial?'(不完整期间)':''),lines);};return o;
|
|
18
|
+
}
|
|
19
|
+
if(d.type==='weekly_transaction_trend'){
|
|
20
|
+
o.xAxis.data=d.points.map(p=>p.start.slice(5)+' 至 '+p.end.slice(5));o.yAxis=[{type:'value',min:0,name:'授权尝试金额 USD',axisLabel:{formatter:v=>v>=1000?(v/1000).toLocaleString()+'k':v},splitLine:{lineStyle:{color:'#e2eae6'}}},{type:'value',min:0,minInterval:1,name:'授权尝试笔数',splitLine:{show:false}}];o.series=[{name:'授权尝试金额 USD',type:'bar',yAxisIndex:0,barMaxWidth:50,data:d.points.map(p=>value(p.amount_usd))},{name:'授权尝试笔数',type:'line',yAxisIndex:1,connectNulls:false,data:d.points.map(p=>p.count)}];o.tooltip.formatter=ps=>{const p=d.points[ps[0]?.dataIndex];return p?wrap(p.start+' 至 '+p.end,['授权尝试金额:'+fmt(p.amount_usd,'usd'),'授权尝试笔数:'+fmt(p.count)+' 笔']):'';};if(!d.points.length)o.graphic={type:'text',left:'center',top:'middle',style:{text:'最近 8 周未观察到数据',fill:'#536770',fontSize:15}};return o;
|
|
21
|
+
}
|
|
22
|
+
const metric=d.metrics.find(m=>m.key===state.metric)||d.metrics[0];
|
|
23
|
+
if(d.layout==='pie_pair'){
|
|
24
|
+
state.el.setAttribute('data-ec-pie','true');delete o.grid;delete o.xAxis;delete o.yAxis;o.legend={type:'scroll',bottom:0,data:d.categories.map(c=>c.label)};o.title=d.series.map((s,i)=>({text:s.label,left:i?'55%':'5%',top:12,textStyle:{fontSize:13,color:'#344a51'}}));o.series=[];o.graphic=[];
|
|
25
|
+
d.series.forEach((s,i)=>{const values=d.categories.map(c=>value(c.values[s.key]?.[metric.key]));if(values.some(v=>v===null||v<0)||!values.some(v=>v>0)){o.graphic.push({type:'text',left:i?'63%':'13%',top:'43%',style:{text:'暂无完整可绘制数据',fill:'#536770',fontSize:14}});return;}o.series.push({name:s.label,type:'pie',radius:['0%','48%'],center:[i?'75%':'25%','48%'],label:{show:false},data:d.categories.map((c,j)=>({name:c.label,value:values[j]}))});});o.tooltip={...tooltip,trigger:'item',formatter:p=>wrap(p.seriesName,[p.name+':'+fmt(p.value,metric.format)])};return o;
|
|
26
|
+
}
|
|
27
|
+
if(d.layout==='histogram_pair'||d.layout==='bars_pair'){
|
|
28
|
+
const histogram=d.layout==='histogram_pair',cats=d.categories.filter(c=>!histogram||!c.is_unknown),all=cats.flatMap(c=>d.series.map(s=>value(c.values[s.key]?.[metric.key]))).filter(v=>v!==null),max=metric.format==='percent'?1:Math.max(...all,1);
|
|
29
|
+
if(!histogram)state.host.style.height=Math.max(350,cats.length*40+135)+'px';
|
|
30
|
+
o.grid=histogram?[{left:75,right:'54%',top:70,bottom:100},{left:'57%',right:40,top:70,bottom:100}]:[{left:260,right:'53%',top:60,bottom:40},{left:'60%',right:85,top:60,bottom:40}];
|
|
31
|
+
o.xAxis=[0,1].map(i=>histogram?{type:'category',gridIndex:i,data:cats.map(c=>c.label),axisLabel:{rotate:30,hideOverlap:false,fontSize:11}}:{type:'value',gridIndex:i,min:0,max,axisLabel:{formatter:v=>fmt(v,metric.format)}});
|
|
32
|
+
o.yAxis=[0,1].map(i=>histogram?{type:'value',gridIndex:i,min:0,max,axisLabel:{formatter:v=>fmt(v,metric.format)}}:{type:'category',gridIndex:i,inverse:true,data:cats.map(c=>c.label),axisLabel:{show:i===0,width:240,overflow:'truncate'},axisTick:{show:false},axisLine:{show:false}});
|
|
33
|
+
o.series=d.series.map((s,i)=>({name:s.label,type:'bar',xAxisIndex:i,yAxisIndex:i,barMaxWidth:histogram?35:15,itemStyle:{color:i?'#087b71':'#727c80'},data:cats.map(c=>value(c.values[s.key]?.[metric.key]))}));o.tooltip.formatter=ps=>{const c=cats[ps[0]?.dataIndex];return c?wrap(c.label,d.series.map(s=>s.label+':'+fmt(c.values[s.key]?.[metric.key],metric.format))):'';};
|
|
34
|
+
if(histogram){const unknown=d.categories.filter(c=>c.is_unknown);if(unknown.length)o.graphic={type:'text',left:12,bottom:0,style:{text:unknown.map(c=>c.label+':'+d.series.map(s=>s.label+' '+fmt(c.values[s.key]?.[metric.key],metric.format)).join(';')).join('\\n'),fill:'#536770',fontSize:11,width:700,overflow:'break'}};}
|
|
35
|
+
return o;
|
|
36
|
+
}
|
|
37
|
+
const rows=[];d.categories.forEach((c,i)=>{rows.push({c,index:i,child:false});if(state.expanded.has(i))(c.children||[]).forEach(c=>rows.push({c,index:i,child:true}));});state.rows=rows;
|
|
38
|
+
const label=r=>(r.child?' ':r.c.children?.length?(state.expanded.has(r.index)?'▾ ':'▸ '):'')+r.c.label;
|
|
39
|
+
state.host.style.height=Math.max(320,rows.length*52+125)+'px';o.grid={left:285,right:175,top:60,bottom:38};o.legend.data=d.series.map(s=>s.label);o.xAxis={type:'value',min:0,max:metric.format==='percent'?1:undefined,axisLabel:{formatter:v=>fmt(v,metric.format)},splitLine:{lineStyle:{color:'#e2eae6'}}};o.yAxis={type:'category',inverse:true,triggerEvent:true,data:rows.map(label),axisLabel:{width:260,overflow:'truncate',color:'#344a51'},axisTick:{show:false},axisLine:{show:false}};
|
|
40
|
+
const get=(c,s)=>metric.key==='auth_rate'&&!(Number(c.values[s.key]?.count)>0)?null:value(c.values[s.key]?.[metric.key]);
|
|
41
|
+
const show=(c,s)=>{const values=c.values[s.key]||{},v=get(c,s);return fmt(v,metric.format)+(metric.key==='auth_rate'&&v!==null?' ('+fmt(values.approvals)+'/'+fmt(values.count)+')':'');};
|
|
42
|
+
o.series=d.series.map((s,i)=>({name:s.label,type:'bar',barMaxWidth:12,itemStyle:{color:i?'#087b71':'#727c80'},data:rows.map(r=>get(r.c,s)),label:{show:true,position:'right',color:i?'#087b71':'#536770',fontSize:12,formatter:p=>show(rows[p.dataIndex].c,s)}}));o.tooltip.axisPointer={type:'shadow'};o.tooltip.formatter=ps=>{const row=rows[ps[0]?.dataIndex];if(!row)return '';return wrap(row.c.label,d.series.map(s=>s.label+':'+show(row.c,s)));};return o;
|
|
43
|
+
}
|
|
44
|
+
function decorateCountry(state){
|
|
45
|
+
if(!state.data.issuer_drilldown)return;
|
|
46
|
+
state.host.querySelectorAll('svg text').forEach(node=>{const index=state.rows.findIndex(r=>!r.child&&r.c.children?.length&&((state.expanded.has(r.index)?'▾ ':'▸ ')+r.c.label)===node.textContent);if(index<0)return;const row=state.rows[index];node.setAttribute('role','button');node.setAttribute('tabindex','0');node.setAttribute('aria-expanded',String(state.expanded.has(row.index)));node.setAttribute('data-ec-country-index',row.index);node.onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();state.expanded.has(row.index)?state.expanded.delete(row.index):state.expanded.add(row.index);state.dirty=true;render(state);requestAnimationFrame(()=>state.host.querySelector('[data-ec-country-index="'+row.index+'"]')?.focus());}};});
|
|
47
|
+
}
|
|
48
|
+
function render(state,force=false){
|
|
49
|
+
if(!force&&(state.host.closest('[role=tabpanel][hidden]')||state.host.getBoundingClientRect().width===0))return;
|
|
50
|
+
if(state.chart&&!state.dirty){const width=state.host.clientWidth,height=state.host.clientHeight;if(width>0&&height>0&&(state.chart.getWidth()!==width||state.chart.getHeight()!==height))state.chart.resize({width,height});return;}
|
|
51
|
+
const opt=option(state);
|
|
52
|
+
for(const axisKey of ['xAxis','yAxis']){const axes=Array.isArray(opt[axisKey])?opt[axisKey]:opt[axisKey]?[opt[axisKey]]:[];axes.forEach((axis,index)=>{if(axis.type!=='value')return;let format='count';if(state.data.type==='risk_dual_axis'&&axisKey==='yAxis'&&index===1)format='percent';else if(state.data.type==='weekly_transaction_trend'&&axisKey==='yAxis'&&index===0)format='usd';else if(state.data.type==='comparison_chart')format=(state.data.metrics.find(m=>m.key===state.metric)||state.data.metrics[0]).format;axis.axisPointer={label:{formatter:p=>fmt(p.value,format)}};});}
|
|
53
|
+
if(!state.chart){state.chart=echarts.init(state.host,null,{renderer:'svg',width:force?(state.host.closest('main')?.clientWidth||1100):undefined});state.chart.on('finished',()=>decorateCountry(state));state.chart.on('click',p=>{if(state.data.type!=='comparison_chart'||!state.data.issuer_drilldown)return;let index=p.componentType==='yAxis'?state.rows.findIndex(r=>((r.child?' ':r.c.children?.length?(state.expanded.has(r.index)?'▾ ':'▸ '):'')+r.c.label)===p.value):p.dataIndex;const row=state.rows[index];if(!row||row.child||!row.c.children?.length)return;state.expanded.has(row.index)?state.expanded.delete(row.index):state.expanded.add(row.index);state.dirty=true;render(state);});}
|
|
54
|
+
const previousSelection=state.chart.getOption()?.legend?.[0]?.selected;if(previousSelection&&opt.legend)opt.legend.selected=previousSelection;
|
|
55
|
+
state.chart.setOption(opt,{notMerge:true});state.chart.resize({width:state.host.clientWidth||1100,height:state.host.clientHeight||430});state.dirty=false;decorateCountry(state);state.el.dataset.ecReady='true';
|
|
56
|
+
}
|
|
57
|
+
document.querySelectorAll('[data-echarts-block]').forEach(el=>{const host=el.querySelector('.ec-host'),data=JSON.parse(el.querySelector('[data-ec-data]').textContent),state={el,host,data,metric:data.default_metric,expanded:new Set(),chart:null,dirty:true};states.push(state);el.querySelector('[data-ec-metric]')?.addEventListener('change',e=>{state.metric=e.target.value;state.dirty=true;render(state);});});
|
|
58
|
+
window.reportECharts={states,renderVisible:()=>states.forEach(s=>render(s)),version:echarts.version};
|
|
59
|
+
const schedule=()=>requestAnimationFrame(()=>states.forEach(s=>render(s)));new ResizeObserver(schedule).observe(document.querySelector('main'));document.querySelectorAll('[role=tab]').forEach(tab=>tab.addEventListener('click',schedule));new MutationObserver(schedule).observe(document.querySelector('main'),{subtree:true,attributes:true,attributeFilter:['hidden']});window.addEventListener('resize',schedule);window.addEventListener('beforeprint',()=>states.forEach(s=>render(s,true)));window.addEventListener('afterprint',schedule);schedule();
|
|
60
|
+
})();
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
document.getElementById('print-report').addEventListener('click',()=>window.print());
|
|
2
|
+
document.querySelectorAll('[role=tablist]').forEach(list=>{const tabs=[...list.querySelectorAll('[role=tab]')];function activate(t){tabs.forEach(x=>{const yes=x===t;x.setAttribute('aria-selected',String(yes));x.tabIndex=yes?0:-1;document.getElementById(x.getAttribute('aria-controls')).hidden=!yes;});}tabs.forEach((t,i)=>{t.addEventListener('click',()=>activate(t));t.addEventListener('keydown',e=>{let j;if(e.key==='ArrowRight')j=(i+1)%tabs.length;else if(e.key==='ArrowLeft')j=(i+tabs.length-1)%tabs.length;else if(e.key==='Home')j=0;else if(e.key==='End')j=tabs.length-1;else return;e.preventDefault();activate(tabs[j]);tabs[j].focus();});});});
|
|
@@ -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)
|