@1aboveio/skills 1.0.22 → 1.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +155 -136
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +11 -3
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +76 -156
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +37 -170
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +32 -15
- package/skills/risk-compliance/portfolio-payment-analysis/references/workflow-and-contracts.md +71 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +10 -1
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.css +1 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.js +60 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/tabs.js +2 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +28 -137
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/echarts_report.py +43 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/interactive_report.py +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/issuer_data.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/payment_amounts.py +20 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +6 -157
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_base.py +164 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_custom.py +30 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_data.py +39 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_model.py +45 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_dual.py +54 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_detail.py +27 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_opened_disputes.py +30 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/LICENSE +222 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/NOTICE +5 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts-provenance.json +6 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts.min.js +45 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
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
|
|
10
|
+
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, child_renderer=None):
|
|
93
|
+
child_renderer=child_renderer or block_output
|
|
94
|
+
kind=block['type'];text=block.get('text','')
|
|
95
|
+
if kind=='paragraph': hp='<p>'+esc(text)+'</p>';mp=mdtext(text)
|
|
96
|
+
elif kind=='note': hp='<p class="note">'+esc(text)+'</p>';mp=mdtext(text)
|
|
97
|
+
elif kind=='callout': hp='<p class="callout">'+esc(text)+'</p>';mp='> '+mdtext(text)
|
|
98
|
+
elif kind=='heading': hp=f'<h{level}>'+esc(text)+f'</h{level}>';mp='#'*level+' '+mdtext(text)
|
|
99
|
+
elif kind=='table': hp,mp=table(block,identifier)
|
|
100
|
+
elif kind=='bars': hp,mp=bars(block)
|
|
101
|
+
elif kind=='line': hp,mp=line_chart(block)
|
|
102
|
+
elif kind=='list':
|
|
103
|
+
hp='<ol>'+''.join('<li>'+esc(x)+'</li>' for x in block['items'])+'</ol>'
|
|
104
|
+
mp='\n'.join(f'{n+1}. {mdtext(x)}' for n,x in enumerate(block['items']))
|
|
105
|
+
elif kind in ('comparison_chart','time_series_chart'): hp,mp=comparison_chart(block,identifier)
|
|
106
|
+
elif kind=='group':
|
|
107
|
+
children=[child_renderer(child,identifier+'-'+str(i),min(level+1,6)) for i,child in enumerate(block['blocks'])]
|
|
108
|
+
hp=''.join(pair[0] for pair in children);mp='\n\n'.join(pair[1] for pair in children)
|
|
109
|
+
if not block.get('collapsed'):
|
|
110
|
+
hp=f'<h{level}>'+esc(block['caption'])+f'</h{level}>'+hp
|
|
111
|
+
mp='#'*level+' '+mdtext(block['caption'])+'\n\n'+mp
|
|
112
|
+
else: raise ValueError('Unknown block type: '+kind)
|
|
113
|
+
if block.get('collapsed'):
|
|
114
|
+
hp='<details><summary>'+esc(block.get('caption','查看明细'))+'</summary>'+hp+'</details>'
|
|
115
|
+
mp='#'*level+' '+mdtext(block.get('caption','查看明细'))+'\n\n'+mp
|
|
116
|
+
return hp,mp
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render(model, output, block_renderer=None, extra_css="", script=None, comparison_js=None):
|
|
120
|
+
block_renderer=block_renderer or block_output
|
|
121
|
+
script=JS if script is None else script
|
|
122
|
+
comparison_js=COMPARISON_JS if comparison_js is None else comparison_js
|
|
123
|
+
output=Path(output);output.mkdir(parents=True,exist_ok=True)
|
|
124
|
+
parts=[]; md=['# '+mdtext(model['title']),mdtext(model.get('subtitle',''))]
|
|
125
|
+
periods=model.get('periods',[])
|
|
126
|
+
md.extend(map(mdtext,periods))
|
|
127
|
+
if model.get('summary'):
|
|
128
|
+
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>')
|
|
129
|
+
md+=['## '+mdtext(model.get('summary_title','本期要点'))]+[f'{i+1}. {mdtext(x)}' for i,x in enumerate(model['summary'])]
|
|
130
|
+
if model.get('kpis'):
|
|
131
|
+
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>')
|
|
132
|
+
md+=['## 核心指标',table({'headers':['指标','数值','说明'],'rows':[[k['label'],k['value'],k.get('note','')] for k in model['kpis']]},'kpis')[1]]
|
|
133
|
+
nav=[]
|
|
134
|
+
for i,section in enumerate(model['sections']):
|
|
135
|
+
sid='section-'+str(i+1)
|
|
136
|
+
nav.append(f'<a href="#{sid}">{esc(section.get("nav",section["title"]))}</a>')
|
|
137
|
+
parts.append(f'<section class="section" id="{sid}"><h2>{esc(section["title"])}</h2>')
|
|
138
|
+
md+=['## '+mdtext(section['title'])]
|
|
139
|
+
for j,b in enumerate(section['blocks']):
|
|
140
|
+
hp,mp=block_renderer(b,sid+'-table-'+str(j))
|
|
141
|
+
parts.append(hp);md.append(mp)
|
|
142
|
+
parts.append('</section>')
|
|
143
|
+
footer=model.get('footer','')
|
|
144
|
+
links=model.get('links',[])
|
|
145
|
+
# Links are local artifacts only; reject script URLs and escaping parent paths.
|
|
146
|
+
for link in links:
|
|
147
|
+
target=Path(link['file'])
|
|
148
|
+
if target.is_absolute() or len(target.parts)!=1 or ':' in link['file'] or target.name in {'.','..'}:
|
|
149
|
+
raise ValueError('Report links must name a sibling file')
|
|
150
|
+
footer_html=esc(footer)
|
|
151
|
+
if links:
|
|
152
|
+
footer_html+='<p>'+ ' · '.join('<a href="'+esc(l['file'])+'">'+esc(l['label'])+'</a>' for l in links)+'</p>'
|
|
153
|
+
md.append(' · '.join('['+l['label']+']('+l['file']+')' for l in links))
|
|
154
|
+
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+extra_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>'+script+comparison_js+'</script></body></html>'
|
|
155
|
+
(output/'overview.zh.html').write_text(page,encoding='utf-8')
|
|
156
|
+
(output/'overview.zh.md').write_text('\n\n'.join(md+[mdtext(footer)])+'\n',encoding='utf-8')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__=='__main__':
|
|
160
|
+
parser=argparse.ArgumentParser(description=__doc__)
|
|
161
|
+
parser.add_argument('--model',required=True,type=Path)
|
|
162
|
+
parser.add_argument('--output',required=True,type=Path)
|
|
163
|
+
args=parser.parse_args()
|
|
164
|
+
render(json.loads(args.model.read_text(encoding='utf-8')),args.output)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared document shell: merchant tabs and comparison-table sparkline slots."""
|
|
2
|
+
import render_base as base
|
|
3
|
+
original=base.block_output
|
|
4
|
+
|
|
5
|
+
def spark(cell):
|
|
6
|
+
points=cell['points']
|
|
7
|
+
text=';'.join(p['date']+' '+(str(p['attempts'])+' 笔' if p['attempts'] is not None else '未观察到数据') for p in points)
|
|
8
|
+
return '<span>'+base.esc(text)+'</span>',text
|
|
9
|
+
|
|
10
|
+
def render_block(block,identifier,level,render_child,render_spark):
|
|
11
|
+
if block['type']=='table' and any(isinstance(v,dict) for row in block['rows'] for v in row):
|
|
12
|
+
plain={**block,'rows':[[render_spark(v)[1] if isinstance(v,dict) else v for v in row] for row in block['rows']]}
|
|
13
|
+
hp,mp=base.table(plain,identifier)
|
|
14
|
+
for row in block['rows']:
|
|
15
|
+
for value in row:
|
|
16
|
+
if isinstance(value,dict):hp=hp.replace('<td>'+base.esc(render_spark(value)[1])+'</td>','<td>'+render_spark(value)[0]+'</td>',1)
|
|
17
|
+
return hp,mp
|
|
18
|
+
if block['type']!='tabs':return original(block,identifier,level,child_renderer=render_child)
|
|
19
|
+
buttons=[];panels=[];markdown=[]
|
|
20
|
+
for i,tab in enumerate(block['tabs']):
|
|
21
|
+
tid=f'{identifier}-tab-{i}';pid=f'{identifier}-panel-{i}'
|
|
22
|
+
buttons.append(f'<button type="button" role="tab" id="{tid}" aria-controls="{pid}" aria-selected="{str(i==0).lower()}" tabindex="{0 if i==0 else -1}">{base.esc(tab["label"])}</button>')
|
|
23
|
+
children=[render_child(child,pid+'-'+str(j),level+1) for j,child in enumerate(tab['blocks'])]
|
|
24
|
+
panels.append(f'<div role="tabpanel" id="{pid}" aria-labelledby="{tid}" tabindex="0"'+(' hidden' if i else '')+'><h3>'+base.esc(tab['label'])+' · '+base.esc(tab['id'])+'</h3>'+''.join(c[0] for c in children)+'</div>')
|
|
25
|
+
markdown.append('### '+base.mdtext(tab['label'])+' · '+base.mdtext(tab['id'])+'\n\n'+'\n\n'.join(c[1] for c in children))
|
|
26
|
+
return '<div class="merchant-tabs"><div role="tablist" aria-label="选择子商户">'+''.join(buttons)+'</div>'+''.join(panels)+'</div>','\n\n'.join(markdown)
|
|
27
|
+
|
|
28
|
+
CSS='''
|
|
29
|
+
.eyebrow{display:none}[role=tablist]{display:flex;gap:4px;overflow-x:auto;border-bottom:1px solid var(--line);padding:4px 0 8px;margin-bottom:24px}[role=tab]{flex:0 0 auto;font:inherit;font-size:14px;padding:10px 14px;background:white;color:var(--ink);border:1px solid var(--line);border-radius:4px;cursor:pointer;min-height:44px}[role=tab]:hover{background:#e9f2ed}[role=tab][aria-selected=true]{background:#087b71;color:white;border-color:#087b71}[role=tabpanel][hidden]{display:none}::selection{background:#bce4d8;color:#142c36}@media print{[role=tablist]{display:none}[role=tabpanel][hidden]{display:block!important}[role=tabpanel]{break-before:page}}
|
|
30
|
+
'''
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Prepare aggregate evidence independently of report layout and chart rendering."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from payment_amounts import attempt_amount_usd
|
|
4
|
+
import issuer_data,risk_dual,vamp_opened_disputes
|
|
5
|
+
import calendar
|
|
6
|
+
|
|
7
|
+
def calculate(layers,end,merchants,rates,calc,auth_end=None):
|
|
8
|
+
cutoff=end;end=auth_end or end;monday=end-timedelta(days=end.weekday())
|
|
9
|
+
periods=[{'key':'previous','label':'上周','start':(monday-timedelta(days=7)).isoformat(),'end':(end-timedelta(days=7)).isoformat()},{'key':'current','label':'本周','start':monday.isoformat(),'end':end.isoformat()}]
|
|
10
|
+
first=monday-timedelta(days=7);days=[(first+timedelta(days=i)).isoformat() for i in range((end-first).days+1)]
|
|
11
|
+
grid=[dict(p,week_start=p['start'],week_end=p['end'],days=len(days)//2,partial_week=end.weekday()!=6) for p in periods]
|
|
12
|
+
sales=risk_dual.SalesIndex(layers['settlement'],cutoff.isoformat());opened=vamp_opened_disputes.configure(layers['dispute'],cutoff.isoformat())
|
|
13
|
+
details=[]
|
|
14
|
+
for m in merchants:
|
|
15
|
+
mid=m.get('id') or '';all_auth=[r for r in layers['auth'] if r['_merchant']==mid];auth=[r for r in all_auth if days[0]<=calc.day(r['Timestamp'])<=days[-1]]
|
|
16
|
+
daily=[]
|
|
17
|
+
for day in days:
|
|
18
|
+
rows=[r for r in auth if calc.day(r['Timestamp'])==day];approved=sum(r.get('Response type','').lower()=='approval' for r in rows)
|
|
19
|
+
daily.append({'date':day,'attempts':len(rows) if rows else None,'approvals':approved if rows else None,'value':approved/len(rows) if rows else None})
|
|
20
|
+
dimensions=calc.merchant_breakdown(auth,grid,rates)
|
|
21
|
+
for d in dimensions:
|
|
22
|
+
for g in d['groups']:g['periods']=[{'key':p['key'],'start':p['start'],'end':p['end'],'metrics':w['metrics']} for p,w in zip(periods,g.pop('weeks'))]
|
|
23
|
+
weekly=[]
|
|
24
|
+
for i in range(7,-1,-1):
|
|
25
|
+
start=monday-timedelta(weeks=i);last=min(start+timedelta(days=6),end);rows=[r for r in all_auth if start.isoformat()<=calc.day(r['Timestamp'])<=last.isoformat()]
|
|
26
|
+
if not rows:continue
|
|
27
|
+
total=attempt_amount_usd(rows,rates,calc.day)
|
|
28
|
+
weekly.append({'start':start.isoformat(),'end':last.isoformat(),'count':len(rows),'amount_usd':total})
|
|
29
|
+
issuer=issuer_data.calculate(dimensions,auth,periods,calc,rates)
|
|
30
|
+
details.append({'id':m.get('id'),'name':m['name'],'dimensions':dimensions,'daily':daily,'weekly':weekly,'issuer_chart':issuer,'summary':{k:v for k,v in m.items() if k not in ('daily_auth',)}})
|
|
31
|
+
risk_end=cutoff;risk_monday=risk_end-timedelta(days=risk_end.weekday())
|
|
32
|
+
windows=[]
|
|
33
|
+
for offset in range(-12,1):
|
|
34
|
+
year,month=divmod(risk_end.year*12+risk_end.month-1+offset,12);month+=1
|
|
35
|
+
windows.append({'start':risk_end.replace(year=year,month=month,day=1).isoformat(),'end':min(risk_end,risk_end.replace(year=year,month=month,day=calendar.monthrange(year,month)[1])).isoformat()})
|
|
36
|
+
for i in range(8):
|
|
37
|
+
first=risk_monday-timedelta(weeks=i);windows.append({'start':first.isoformat(),'end':min(first+timedelta(days=6),risk_end).isoformat()})
|
|
38
|
+
risk_sales=[{'id':m.get('id'),'start':p['start'],'end':p['end'],'brand':brand,'count':sales.denominator(m.get('id'),p['start'],p['end'],brand)} for m in merchants for p in windows for brand in ['visa','mastercard']]
|
|
39
|
+
return {'auth_end':end.isoformat(),'common_end':cutoff.isoformat(),'periods':periods,'merchants':details,'opened_disputes':opened,'risk_sales':risk_sales}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Construct report structure from frozen aggregate evidence."""
|
|
2
|
+
import risk_dual
|
|
3
|
+
|
|
4
|
+
def risk_charts(facts,history,builder):
|
|
5
|
+
"""Use one metric projection for the report and its risk CSV companion."""
|
|
6
|
+
evidence=facts.get('interactive_report') or {}
|
|
7
|
+
denominators={tuple(x[k] for k in ['id','start','end','brand']):x['count'] for x in evidence.get('risk_sales',[])}
|
|
8
|
+
opened_evidence=evidence.get('opened_disputes')
|
|
9
|
+
opened_counts=({(r['merchant_id'],r['month']):r['opened_formal_cases'] for r in opened_evidence['merchant_month_counts']} if opened_evidence is not None and 'merchant_month_counts' in opened_evidence else None)
|
|
10
|
+
import vamp_detail
|
|
11
|
+
return risk_dual.convert(vamp_detail.risk_charts(history,builder,opened_counts),(history or {}).get('id'),denominators)
|
|
12
|
+
|
|
13
|
+
def build_model(facts,model,builder):
|
|
14
|
+
evidence=facts.get('interactive_report')
|
|
15
|
+
# Old saved facts still render, but explicitly omit unsupported derived series.
|
|
16
|
+
details={d['id']:d for d in (evidence or {}).get('merchants',[])};periods=(evidence or {}).get('periods',[])
|
|
17
|
+
merchants=list(facts['merchants']);existing={m.get('id') for m in merchants}
|
|
18
|
+
for d in details.values():
|
|
19
|
+
if d['id'] not in existing and d.get('summary'):merchants.append(d['summary']);existing.add(d['id'])
|
|
20
|
+
ordered=sorted((m for m in merchants if m.get('id')),key=lambda m:(-m['attempts'],m['id']));maximum=max((p['attempts'] or 0 for d in details.values() for p in d['daily']),default=1) or 1
|
|
21
|
+
rows=[];tabs=[];histories={h['id']:h for h in facts.get('merchant_risk_history',[])}
|
|
22
|
+
for m in ordered:
|
|
23
|
+
mid=m.get('id');d=details.get(mid,{});observed=m.get('layer_observed',{}).get('auth',False)
|
|
24
|
+
spark={'type':'sparkline','metric':'count','max':maximum,'points':d.get('daily') or [{'date':facts['metadata']['main_end'],'attempts':None,'approvals':None,'value':None}]}
|
|
25
|
+
rows.append([builder.name(m),mid or '未归属',builder.number(m['attempts']) if observed else '',builder.number(m['approvals']) if observed else '',builder.percent(m['auth_rate']),spark,builder.amount(m,'approved_amount'),builder.number(m.get('refund_count')),builder.amount(m,'refunds',True),builder.number(m.get('dispute_snapshots'))])
|
|
26
|
+
charts=builder.segment_charts(d,periods) if d else []
|
|
27
|
+
for dim in d.get('dimensions',[]):
|
|
28
|
+
if not any(b.get('title')==dim['label'] for b in charts):charts.append({'type':'comparison_chart','title':dim['label'],'series':[{'key':p['key'],'label':p['label']+' '+p['start']+' 至 '+p['end']} for p in periods],'metrics':[{'key':'count','label':'授权尝试','format':'count'}],'default_metric':'count','categories':[{'label':'未观察到数据','values':{'previous':{'count':None},'current':{'count':None}}}]})
|
|
29
|
+
for b in charts:
|
|
30
|
+
if b['title']=='发卡行国家' or b['title'].startswith('卡产品'):
|
|
31
|
+
b['metrics']=[{'key':'auth_rate','label':'授权成功率','format':'percent'}];b['default_metric']='auth_rate';b['approval_rate_only']=True
|
|
32
|
+
# Issuer children were calculated during analysis, never from descriptors.
|
|
33
|
+
for b in charts:
|
|
34
|
+
if b['title']=='发卡行国家' and d.get('issuer_chart'):
|
|
35
|
+
issuer=d['issuer_chart'];children={c['label']:c['children'] for c in issuer['categories'] if 'children' in c}
|
|
36
|
+
for category in b['categories']:
|
|
37
|
+
if category['label'] in children:category['children']=children[category['label']]
|
|
38
|
+
b.update({k:v for k,v in issuer.items() if k!='categories'});b['metrics']=[{'key':'auth_rate','label':'授权成功率','format':'percent'}];b['default_metric']='auth_rate';b['approval_rate_only']=True
|
|
39
|
+
risk=risk_charts(facts,histories.get(mid),builder)
|
|
40
|
+
tabs.append({'id':mid or 'unknown','label':builder.name(m),'blocks':[{'type':'weekly_transaction_trend','title':'交易趋势 · 最近 8 周(有数据周)','points':d.get('weekly',[])},*charts,*risk]})
|
|
41
|
+
model['sections']=[{'title':'1. 子商户对比','nav':'子商户对比','blocks':[builder.table(['子商户','商户 ID','授权尝试','授权成功','授权成功率','每日授权笔数','授权成功金额 USD','退款笔数','退款金额 USD','争议笔数'],rows)]},{'title':'3. 重点子商户详情','nav':'商户详情','blocks':[{'type':'tabs','tabs':tabs}]}]
|
|
42
|
+
model['links']=[x for x in model['links'] if x['file'] not in ['merchant-weekly.csv']]
|
|
43
|
+
auth_end=(evidence or {}).get('auth_end',facts['metadata']['main_end']);common_end=facts['metadata']['main_end']
|
|
44
|
+
model['periods']=[f"对比表统计期:{facts['metadata']['main_start']} 至 {common_end};请求截止:{facts['metadata'].get('requested_main_end',common_end)}。",f"授权趋势及细分截至 {auth_end};风险历史截至 {common_end}。未观察到数据的日期保留空位。"]
|
|
45
|
+
return model
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Dual-axis count bars and rate lines for merchant risk histories."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
class SalesIndex:
|
|
4
|
+
"""One analysis run's verified sales; no process-wide retained state."""
|
|
5
|
+
def __init__(self,settlements,cutoff):
|
|
6
|
+
self.sales=[r for r in settlements if r.get('Settlement type')=='Sale' and r.get('Settlement status')=='Settled' and ' '.join(r.get('Provider','').split()).casefold()=='worldpay - acquiring' and r.get('Settlement date','')[:10]<=cutoff and r.get('Timestamp','')[:10]<=cutoff and r.get('Transaction ID') and r.get('_merchant')]
|
|
7
|
+
self.cache={}
|
|
8
|
+
def denominator(self,mid,start,end,brand):
|
|
9
|
+
key=(mid,start,end,brand)
|
|
10
|
+
if key not in self.cache:
|
|
11
|
+
n=sum(r['_merchant']==mid and start<=r['Timestamp'][:10]<=end and r.get('Payment method','').casefold()==brand for r in self.sales)
|
|
12
|
+
self.cache[key]=n if n else None
|
|
13
|
+
return self.cache[key]
|
|
14
|
+
|
|
15
|
+
def convert(charts,mid,denominators=None):
|
|
16
|
+
denominators=denominators or {}
|
|
17
|
+
out=[]
|
|
18
|
+
for original in charts:
|
|
19
|
+
b=deepcopy(original);b['type']='risk_dual_axis';weekly='周度' in b['title'];vamp='VAMP' in b['title'];fraud='欺诈报告' in b['title']
|
|
20
|
+
if vamp:
|
|
21
|
+
b['count_series']=[{'key':'vamp_count','label':'VAMP 分子笔数'}];b['rate_series']=[{'key':'vamp_ratio','label':'VAMP(估算)'}]
|
|
22
|
+
else:
|
|
23
|
+
b['count_series']=b['series']
|
|
24
|
+
if weekly:b['rate_series']=[{'key':s['key'],'label':s['label']+' 周度观察比率'} for s in b['series'] if s['key']!='other']
|
|
25
|
+
else:b['rate_series']=deepcopy(next(v for v in b['views'] if v['key']=='rate').get('series',b['series']))
|
|
26
|
+
points=[]
|
|
27
|
+
for p in b['points']:
|
|
28
|
+
point={'label':p['label'],'start':p['start'],'end':p['end'],'partial':p.get('partial',False),'counts':{},'rates':{},'details':{}}
|
|
29
|
+
if vamp:
|
|
30
|
+
d=p['rate_details']['vamp_ratio'];point['counts']['vamp_count']=d['numerator'];point['rates']['vamp_ratio']=p['values']['vamp_ratio'];point['details']['vamp_ratio']=d
|
|
31
|
+
else:
|
|
32
|
+
point['counts']=p['values']
|
|
33
|
+
for s in b['rate_series']:
|
|
34
|
+
k=s['key']
|
|
35
|
+
if weekly:
|
|
36
|
+
num=p['values'].get(k);brand='mastercard' if k.startswith('mastercard') else 'visa';den=denominators.get((mid,p['start'],p['end'],brand))
|
|
37
|
+
d={'numerator':num,'denominator':den,'denominator_start':p['start'],'denominator_end':p['end'],'formula':'same-week merchant and brand observed count / settled Sale count by transaction/capture date','status':'descriptive_weekly_ratio'}
|
|
38
|
+
value=num/den if num is not None and den is not None and den>0 else None
|
|
39
|
+
else:
|
|
40
|
+
d=deepcopy(p['rate_details'][k]);value=p.get('rates',{}).get(k)
|
|
41
|
+
# Already-opened formal disputes count independently of resolution.
|
|
42
|
+
if k in ('visa_dispute','mastercard_chargeback'):
|
|
43
|
+
d['numerator']=p['values'].get(k);den=d.get('denominator');num=d['numerator'];value=num/den if num is not None and den and den>0 else None
|
|
44
|
+
d['formula']='opened formal disputes / same-month Visa or prior-month Mastercard settled Sales';d['status']='extract_estimate'
|
|
45
|
+
point['rates'][k]=value;point['details'][k]=d
|
|
46
|
+
points.append(point)
|
|
47
|
+
b['points']=points
|
|
48
|
+
b.pop('views',None);b.pop('default_view',None)
|
|
49
|
+
if weekly:b['dual_note']='柱状图为观察数量;折线为同商户、同卡组织、同周观察数量 / 已结算 Sale 笔数(按交易/捕获日期)。周度比率是描述性指标,不是卡组织月度监控率。'
|
|
50
|
+
elif vamp:b['dual_note']='柱状图为 TC40 + 已开立正式争议的分子笔数;折线为该分子 / 同商户当月 Visa 销售笔数(导出估算)。'
|
|
51
|
+
elif fraud:b['dual_note']='柱状图为 340FRD 欺诈报告数量。Visa 折线使用同月 Visa 销售;Mastercard 折线为 EFM 欺诈拒付 / 上月电商销售,分子与 Mastercard 欺诈报告柱不同,未确认时留空。'
|
|
52
|
+
else:b['dual_note']='柱状图为各阶段争议案件数。Visa 比率使用同月 Visa 销售,Mastercard 使用上月 Mastercard 销售;正式争议一经开立即计数,不以结案为条件。预争议单独展示。'
|
|
53
|
+
out.append(b)
|
|
54
|
+
return out
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""VAMP extract estimates from confirmed TC40 and already-opened formal disputes."""
|
|
2
|
+
import vamp_opened_disputes
|
|
3
|
+
|
|
4
|
+
def evidence(history,opened_counts=None):
|
|
5
|
+
if not history:return []
|
|
6
|
+
fraud={p['label']:p for p in history['fraud']['monthly']};disputes={p['label']:p for p in history['disputes']['monthly']};rows=[]
|
|
7
|
+
for month in history['vamp']['monthly']:
|
|
8
|
+
key=month['label'];f=fraud[key];d=disputes[key];fr=f.get('rate_details',{}).get('visa',{});dr=d.get('rate_details',{}).get('visa_dispute',{})
|
|
9
|
+
denom=[v for v in [fr.get('denominator'),dr.get('denominator')] if v is not None];assert len(set(denom))<=1,'Visa denominator disagreement'
|
|
10
|
+
sales=denom[0] if denom else None;tc40=f.get('values',{}).get('visa');opened=vamp_opened_disputes.count(history['id'],key,d.get('values',{}).get('visa_dispute') is not None,opened_counts)
|
|
11
|
+
numerator=tc40+opened if tc40 is not None and opened is not None else None
|
|
12
|
+
ratio=numerator/sales if numerator is not None and sales is not None and sales>0 else None
|
|
13
|
+
status='extract_estimate' if ratio is not None else 'missing_denominator' if numerator is not None else 'missing_numerator'
|
|
14
|
+
rows.append({'month':key,'start':month['start'],'end':month['end'],'partial':month.get('partial',False),'visa_fraud_observations':tc40,'visa_tc40_records':tc40,'tc40_source_status':'confirmed_by_user_dart340_visa','visa_dispute_stage_observations':d.get('values',{}).get('visa_dispute'),'supported_formal_openings':opened,'request_only_openings_legacy':dr.get('numerator'),'visa_sale_observations':sales,'numerator':numerator,'estimated_vamp_ratio':ratio,'official_vamp_ratio':None,'status':status,'basis':'same-merchant Visa TC40 file-generation month + earliest supplied formal-dispute observation month, divided by Visa sales transaction/capture month','missing':['network TC15/TC05 processing-month reconciliation','VisaNet CNP eligibility and exclusions','descriptor/acquirer/market grouping','complete reporting population']})
|
|
15
|
+
return rows
|
|
16
|
+
|
|
17
|
+
def risk_charts(history,builder,opened_counts=None):
|
|
18
|
+
charts=builder.risk_charts(history)
|
|
19
|
+
if not history:return charts
|
|
20
|
+
rows=evidence(history,opened_counts)
|
|
21
|
+
for chart in charts:
|
|
22
|
+
if chart.get('title')=='VAMP · 月度':
|
|
23
|
+
chart['title']='VAMP · 月度(导出估算)';chart['series']=[{'key':'vamp_ratio','label':'VAMP(估算)'}]
|
|
24
|
+
chart['status_note']='(Visa TC40 + 已开立正式争议)/ 同商户当月 Visa 销售笔数;采用现有导出日期口径估算,非卡组织正式认定。'
|
|
25
|
+
chart['date_basis']='TC40 按文件生成日;正式争议按最早提供的正式记录日期;Visa 销售按交易/捕获日期。'
|
|
26
|
+
chart['points']=[{'label':r['month'],'start':r['start'],'end':r['end'],'partial':r['partial'],'values':{'vamp_ratio':r['estimated_vamp_ratio'] if r['estimated_vamp_ratio'] is not None and r['numerator']>0 else None},'rate_details':{'vamp_ratio':{'numerator':r['numerator'],'denominator':r['visa_sale_observations'],'denominator_start':r['start'],'denominator_end':r['end'],'status':r['status'],'formula':'(Visa TC40 records + opened formal Visa disputes) / same-merchant same-month Visa settled Sale count','reason':'Extract-date estimate; network processing dates, eligibility and exclusions are not certified.'}}} for r in rows]
|
|
27
|
+
return charts
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Count already-opened Visa formal disputes, independent of case resolution."""
|
|
2
|
+
from collections import defaultdict,Counter
|
|
3
|
+
import risk_history
|
|
4
|
+
|
|
5
|
+
def configure(disputes,cutoff):
|
|
6
|
+
cases=defaultdict(list);audit=Counter();totals=Counter()
|
|
7
|
+
for r in disputes:
|
|
8
|
+
if r['Chargeback date'][:10]>cutoff:continue
|
|
9
|
+
provider=' '.join(r.get('Provider','').split()).casefold()
|
|
10
|
+
key=(provider,r.get('MID'),r.get('Dispute ID'))
|
|
11
|
+
if not all(key):audit['missing_case_identity_rows']+=1;continue
|
|
12
|
+
cases[key].append(r)
|
|
13
|
+
for rows in cases.values():
|
|
14
|
+
mids={r.get('_merchant') for r in rows};brands={risk_history.brand_of(r) for r in rows}
|
|
15
|
+
if len(mids)!=1 or not next(iter(mids)) or len(brands)!=1:
|
|
16
|
+
audit['conflicting_or_unknown_cases_excluded']+=1;continue
|
|
17
|
+
if next(iter(brands))!='visa' or not risk_history.verified_provider(rows[0]):continue
|
|
18
|
+
formal=[r for r in rows if risk_history.stage(r)=='visa_dispute']
|
|
19
|
+
if not formal:
|
|
20
|
+
if any(risk_history.stage(r)=='visa_predispute' for r in rows):audit['rdr_only_cases_excluded']+=1
|
|
21
|
+
continue
|
|
22
|
+
first=min(r['Chargeback date'][:10] for r in formal)
|
|
23
|
+
totals[(next(iter(mids)),first[:7])]+=1
|
|
24
|
+
audit['opened_formal_cases_counted']+=1
|
|
25
|
+
for state in {r.get('Lifecycle state') for r in formal}:audit['case_with_status_'+state]+=1
|
|
26
|
+
return {'rule':'An opened formal Visa dispute counts regardless of pending/response/loss/reversal status; one per provider+MID+case identity. RDR-only excluded.','date_basis':'earliest supplied formal-stage Chargeback date; not certified network processing month','audit':dict(audit),'merchant_month_counts':[{'merchant_id':m,'month':month,'opened_formal_cases':n} for (m,month),n in sorted(totals.items())]}
|
|
27
|
+
|
|
28
|
+
def count(mid,month,has_observations,counts=None):
|
|
29
|
+
if counts is None:return None
|
|
30
|
+
return counts.get((mid,month),0) if has_observations else None
|