@1aboveio/skills 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +298 -216
  3. package/README.md +3 -3
  4. package/package.json +10 -8
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +35 -37
  7. package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
  8. package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
  9. package/skills/app-dev/android-foundation/references/setup.md +24 -9
  10. package/skills/app-dev/android-foundation/references/workspace.md +81 -0
  11. package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  12. package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  13. package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  14. package/skills/app-dev/app-debug/SKILL.md +1 -1
  15. package/skills/app-dev/better-auth/SKILL.md +1 -1
  16. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  17. package/skills/app-dev/data-quality/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/SKILL.md +15 -10
  19. package/skills/app-dev/ios-foundation/references/setup.md +25 -9
  20. package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
  21. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  22. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  23. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +3 -3
  50. package/skills/core/distill-writing/references/extraction.md +1 -1
  51. package/skills/core/doc-to-md/SKILL.md +1 -1
  52. package/skills/core/editorial-review/SKILL.md +82 -12
  53. package/skills/core/editorial-review/references/checklist.md +26 -1
  54. package/skills/core/house-style/SKILL.md +8 -3
  55. package/skills/core/house-style/references/analytical-reporting.md +123 -0
  56. package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
  57. package/skills/core/house-style/references/analytical-writing-research.md +129 -0
  58. package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
  59. package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
  60. package/skills/core/house-style/references/document-workflow.md +193 -0
  61. package/skills/core/house-style/references/initialization.md +66 -0
  62. package/skills/core/house-style/references/persistence.md +9 -4
  63. package/skills/core/house-style/references/rules.md +11 -4
  64. package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
  65. package/skills/core/house-style/templates/analytical-report.md +54 -0
  66. package/skills/core/house-style/templates/gap-analysis.md +1 -1
  67. package/skills/core/plain-language/SKILL.md +2 -2
  68. package/skills/core/polish-cn/SKILL.md +9 -2
  69. package/skills/core/reg-parser/SKILL.md +3 -3
  70. package/skills/core/terminology/SKILL.md +8 -4
  71. package/skills/core/write-doc/SKILL.md +40 -151
  72. package/skills/core/write-report/SKILL.md +129 -0
  73. package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
  74. package/skills/engineering/better-review/SKILL.md +37 -37
  75. package/skills/engineering/better-review/references/passes.md +35 -41
  76. package/skills/engineering/better-review/references/result.md +10 -9
  77. package/skills/engineering/e2e-test/SKILL.md +17 -17
  78. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
  79. package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
  80. package/skills/engineering/e2e-test/references/reviewing.md +9 -11
  81. package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
  82. package/skills/engineering/engineering/SKILL.md +201 -0
  83. package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
  84. package/skills/engineering/engineering/references/feature-delivery.md +8 -0
  85. package/skills/engineering/engineering/references/init-scope.md +45 -0
  86. package/skills/engineering/engineering/references/scope-management.md +85 -0
  87. package/skills/engineering/engineering/scripts/init.mjs +160 -0
  88. package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
  89. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  90. package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
  91. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  92. package/skills/engineering/ensure-coverage/SKILL.md +22 -109
  93. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
  94. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
  95. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
  96. package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
  97. package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
  98. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
  99. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
  100. package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
  101. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
  102. package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
  103. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
  104. package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
  105. package/skills/engineering/golden-path/SKILL.md +1 -1
  106. package/skills/engineering/harness-runtime/SKILL.md +3 -1
  107. package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
  108. package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
  109. package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
  110. package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
  111. package/skills/engineering/resolve-issues/SKILL.md +40 -61
  112. package/skills/engineering/resolve-issues/references/ci.md +13 -3
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
  114. package/skills/engineering/resolve-issues/references/done.md +11 -2
  115. package/skills/engineering/resolve-issues/references/expire.md +9 -2
  116. package/skills/engineering/resolve-issues/references/implementation.md +33 -17
  117. package/skills/engineering/resolve-issues/references/preflight.md +3 -1
  118. package/skills/engineering/resolve-issues/references/principles.md +52 -0
  119. package/skills/engineering/resolve-issues/references/review.md +25 -15
  120. package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
  121. package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
  122. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
  123. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
  124. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
  125. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
  126. package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
  127. package/skills/engineering/resolve-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-issues/SKILL.md +7 -5
  129. package/skills/engineering/rush-release/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/SKILL.md +19 -4
  131. package/skills/engineering/test-quality/references/lint.md +1 -1
  132. package/skills/engineering/test-quality/references/principles.md +2 -2
  133. package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
  134. package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
  135. package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
  136. package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
  137. package/skills/risk-compliance/contract-review/SKILL.md +6 -1
  138. package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
  139. package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
  140. package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
  141. package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
  142. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
  143. package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
  144. package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
  145. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
  146. package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
  147. package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
  148. package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
  149. package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
  150. package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
  151. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
  152. package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
  153. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
  154. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
  155. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
  156. package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
  157. package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
  158. package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
  159. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
  160. package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
  161. package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
  162. package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
  163. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
  164. package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
  165. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
  166. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
  167. package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
  168. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
  169. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
  170. package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
  171. package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
  172. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
  173. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
  174. package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
  175. package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
  176. package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
  177. package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
  178. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
  179. package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
  180. package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
  181. package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
  182. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
  183. package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
  184. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
  185. package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
  186. package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
  187. package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
  188. package/skills/engineering/contributing/SKILL.md +0 -70
  189. package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
  190. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
  191. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
  192. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
  193. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
  194. package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
  195. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
  196. package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
  197. package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
  198. package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
  199. package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
  200. package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
  201. /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
  202. /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
  203. /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
  204. /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
  205. /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
  206. /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
  207. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
  208. /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
  209. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
  210. /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
  211. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
  212. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
  213. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
  214. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
@@ -0,0 +1,160 @@
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):
93
+ kind=block['type'];text=block.get('text','')
94
+ if kind=='paragraph': hp='<p>'+esc(text)+'</p>';mp=mdtext(text)
95
+ elif kind=='note': hp='<p class="note">'+esc(text)+'</p>';mp=mdtext(text)
96
+ elif kind=='callout': hp='<p class="callout">'+esc(text)+'</p>';mp='> '+mdtext(text)
97
+ elif kind=='heading': hp=f'<h{level}>'+esc(text)+f'</h{level}>';mp='#'*level+' '+mdtext(text)
98
+ elif kind=='table': hp,mp=table(block,identifier)
99
+ elif kind=='bars': hp,mp=bars(block)
100
+ elif kind=='line': hp,mp=line_chart(block)
101
+ elif kind=='list':
102
+ hp='<ol>'+''.join('<li>'+esc(x)+'</li>' for x in block['items'])+'</ol>'
103
+ mp='\n'.join(f'{n+1}. {mdtext(x)}' for n,x in enumerate(block['items']))
104
+ elif kind in ('comparison_chart','time_series_chart'): hp,mp=comparison_chart(block,identifier)
105
+ elif kind=='group':
106
+ children=[block_output(child,identifier+'-'+str(i),min(level+1,6)) for i,child in enumerate(block['blocks'])]
107
+ hp=''.join(pair[0] for pair in children);mp='\n\n'.join(pair[1] for pair in children)
108
+ if not block.get('collapsed'):
109
+ hp=f'<h{level}>'+esc(block['caption'])+f'</h{level}>'+hp
110
+ mp='#'*level+' '+mdtext(block['caption'])+'\n\n'+mp
111
+ else: raise ValueError('Unknown block type: '+kind)
112
+ if block.get('collapsed'):
113
+ hp='<details><summary>'+esc(block.get('caption','查看明细'))+'</summary>'+hp+'</details>'
114
+ mp='#'*level+' '+mdtext(block.get('caption','查看明细'))+'\n\n'+mp
115
+ return hp,mp
116
+
117
+
118
+ def render(model, output):
119
+ output=Path(output);output.mkdir(parents=True,exist_ok=True)
120
+ parts=[]; md=['# '+mdtext(model['title']),mdtext(model.get('subtitle',''))]
121
+ periods=model.get('periods',[])
122
+ md.extend(map(mdtext,periods))
123
+ if model.get('summary'):
124
+ parts.append('<section class="summary"><h2>'+esc(model.get('summary_title','本期要点'))+'</h2><ol>'+''.join('<li>'+esc(x)+'</li>' for x in model['summary'])+'</ol></section>')
125
+ md+=['## '+mdtext(model.get('summary_title','本期要点'))]+[f'{i+1}. {mdtext(x)}' for i,x in enumerate(model['summary'])]
126
+ if model.get('kpis'):
127
+ parts.append('<div class="kpis">'+''.join('<div class="kpi"><div class="kpi-label">'+esc(k['label'])+'</div><div class="kpi-value">'+esc(k['value'])+'</div><small>'+esc(k.get('note',''))+'</small></div>' for k in model['kpis'])+'</div>')
128
+ md+=['## 核心指标',table({'headers':['指标','数值','说明'],'rows':[[k['label'],k['value'],k.get('note','')] for k in model['kpis']]},'kpis')[1]]
129
+ nav=[]
130
+ for i,section in enumerate(model['sections']):
131
+ sid='section-'+str(i+1)
132
+ nav.append(f'<a href="#{sid}">{esc(section.get("nav",section["title"]))}</a>')
133
+ parts.append(f'<section class="section" id="{sid}"><h2>{esc(section["title"])}</h2>')
134
+ md+=['## '+mdtext(section['title'])]
135
+ for j,b in enumerate(section['blocks']):
136
+ hp,mp=block_output(b,sid+'-table-'+str(j))
137
+ parts.append(hp);md.append(mp)
138
+ parts.append('</section>')
139
+ footer=model.get('footer','')
140
+ links=model.get('links',[])
141
+ # Links are local artifacts only; reject script URLs and escaping parent paths.
142
+ for link in links:
143
+ target=Path(link['file'])
144
+ if target.is_absolute() or len(target.parts)!=1 or ':' in link['file'] or target.name in {'.','..'}:
145
+ raise ValueError('Report links must name a sibling file')
146
+ footer_html=esc(footer)
147
+ if links:
148
+ footer_html+='<p>'+ ' · '.join('<a href="'+esc(l['file'])+'">'+esc(l['label'])+'</a>' for l in links)+'</p>'
149
+ md.append(' · '.join('['+l['label']+']('+l['file']+')' for l in links))
150
+ page='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>'+esc(model['title'])+'</title><style>'+CSS+COMPARISON_CSS+'</style></head><body><header class="mast"><div class="eyebrow">Submerchant / Payment Review</div><h1>'+esc(model['title'])+'</h1><p>'+esc(model.get('subtitle',''))+'</p><div class="periods">'+''.join('<span>'+esc(x)+'</span>' for x in periods)+'</div></header><nav class="toolbar" aria-label="报告导航">'+''.join(nav)+'<button type="button" id="print-report">打印 / 保存 PDF</button></nav><main>'+''.join(parts)+'<footer class="footer">'+footer_html+'</footer></main><script>'+JS+COMPARISON_JS+'</script></body></html>'
151
+ (output/'overview.zh.html').write_text(page,encoding='utf-8')
152
+ (output/'overview.zh.md').write_text('\n\n'.join(md+[mdtext(footer)])+'\n',encoding='utf-8')
153
+
154
+
155
+ if __name__=='__main__':
156
+ parser=argparse.ArgumentParser(description=__doc__)
157
+ parser.add_argument('--model',required=True,type=Path)
158
+ parser.add_argument('--output',required=True,type=Path)
159
+ args=parser.parse_args()
160
+ render(json.loads(args.model.read_text(encoding='utf-8')),args.output)
@@ -0,0 +1,435 @@
1
+ """Observed merchant histories. Raw DART fields and join keys never leave this module."""
2
+ import calendar
3
+ import csv
4
+ import hashlib
5
+ import io
6
+ import re
7
+ from collections import Counter, defaultdict
8
+ from datetime import date, datetime, timedelta
9
+ from decimal import Decimal, InvalidOperation
10
+
11
+ # SIX ISO 4217 list-one snapshot, 2026-09-17; membership, not historical validity.
12
+ CURRENCIES = set('AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BHD BIF BMD BND BOB BOV BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SHP SLE SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU UYW UZS VED VES VND VUV WST XAD XAF XAG XAU XBA XBB XBC XBD XCD XCG XDR XOF XPD XPF XPT XSU XTS XUA XXX YER ZAR ZMW ZWG'.split())
13
+ FILENAME = re.compile(r'WP_[A-Z0-9]+_340FRD_V03_([0-9]{8})_[0-9]{3}\.CSV')
14
+ LENGTHS = (2, 8, 80, 15, 19, 8, 3, 23, 250, 255, 23, 15, 3, 30, 3, 3, 1)
15
+ TYPES = set('900 901 902 903 904 905 906 907 908 909 90A 90B 955 956'.split())
16
+
17
+
18
+ def dart_date(value):
19
+ if not re.fullmatch(r'\d{8}', value):
20
+ raise ValueError('date_format')
21
+ try:
22
+ return datetime.strptime(value, '%Y%m%d').date()
23
+ except ValueError:
24
+ raise ValueError('date_calendar') from None
25
+
26
+
27
+ def parse_file(name, data):
28
+ match = FILENAME.fullmatch(name)
29
+ if not match:
30
+ raise ValueError('filename_format')
31
+ created = dart_date(match[1])
32
+ try:
33
+ text = data.decode('ascii')
34
+ if any(ord(c) < 32 and c not in '\r\n\t' or ord(c) == 127 for c in text):
35
+ raise ValueError('ascii_control_character')
36
+ records = list(csv.reader(io.StringIO(text, newline=''), strict=True))
37
+ except UnicodeDecodeError:
38
+ raise ValueError('ascii_encoding') from None
39
+ except csv.Error:
40
+ raise ValueError('csv_syntax') from None
41
+ if not records or any(not row for row in records):
42
+ raise ValueError('empty_file_or_record')
43
+ if records[0][0] != '00':
44
+ raise ValueError('missing_header')
45
+ if len(records[0]) != 17:
46
+ raise ValueError('header_field_count')
47
+ if dart_date(records[0][1]) != created:
48
+ raise ValueError('header_filename_date_mismatch')
49
+ if any(records[0][2:]):
50
+ raise ValueError('header_reserved_not_empty')
51
+ if len(records) < 2 or records[-1][0] != '99':
52
+ raise ValueError('missing_trailer')
53
+ trailer = records[-1]
54
+ if len(trailer) != 2 or not re.fullmatch(r'[0-9]{1,23}', trailer[1]) or int(trailer[1]) != len(records)-1:
55
+ raise ValueError('invalid_trailer')
56
+ rows = []
57
+ for row in records[1:-1]:
58
+ if row[0] != '01' or len(row) != 17:
59
+ raise ValueError('invalid_detail')
60
+ if any(len(v) > n for v, n in zip(row, LENGTHS)):
61
+ raise ValueError('field_max_length')
62
+ dart_date(row[5])
63
+ if row[6] not in CURRENCIES or (row[12] and row[12] not in CURRENCIES):
64
+ raise ValueError('iso4217_currency')
65
+ if not re.fullmatch(r'-?[0-9]+(?:\.[0-9]{1,6})?', row[7]):
66
+ raise ValueError('major_units_decimal_format')
67
+ if row[14] not in TYPES or row[15] not in {'SWP', 'PKE', 'NA'}:
68
+ raise ValueError('invalid_code')
69
+ rows.append(tuple(row))
70
+ return created, rows
71
+
72
+
73
+ def brand_of(row):
74
+ return ' '.join((row.get('Payment method') or '').split()).casefold()
75
+
76
+
77
+ def verified_provider(row):
78
+ return ' '.join((row.get('Provider') or '').split()).casefold() == 'worldpay - acquiring'
79
+
80
+
81
+ def affected_series(brand):
82
+ return {'visa': ('visa_predispute', 'visa_dispute'),
83
+ 'mastercard': ('mastercard_chargeback',)}.get(
84
+ brand, ('visa_predispute', 'visa_dispute', 'mastercard_chargeback'))
85
+
86
+
87
+ def stage(row):
88
+ brand = brand_of(row)
89
+ pair = (row.get('Lifecycle state', ''), row.get('Original cycle', ''))
90
+ if brand == 'visa' and pair == ('Pre-dispute resolution', 'Rapid Dispute Resolution'):
91
+ return 'visa_predispute'
92
+ formal = {('1st chargeback', 'First CB Response'), ('1st chargeback', 'First CB Request'),
93
+ ('Initial dispute loss notice', 'First CB Dispute Loss'), ('Initial dispute reversal', 'First CB Response')}
94
+ if pair in formal:
95
+ return {'visa': 'visa_dispute', 'mastercard': 'mastercard_chargeback'}.get(brand)
96
+ return None
97
+
98
+
99
+ def periods(cutoff):
100
+ months = []
101
+ for offset in range(-11, 1):
102
+ y, m = divmod(cutoff.year * 12 + cutoff.month - 1 + offset, 12)
103
+ first = date(y, m+1, 1)
104
+ last = min(date(y, m+1, calendar.monthrange(y, m+1)[1]), cutoff)
105
+ months.append((first.strftime('%Y-%m'), first, last, last.day != calendar.monthrange(y, m+1)[1]))
106
+ monday = cutoff - timedelta(days=cutoff.weekday())
107
+ weeks = []
108
+ for i in range(7, -1, -1):
109
+ first = monday - timedelta(weeks=i)
110
+ last = min(first + timedelta(days=6), cutoff)
111
+ weeks.append((first.isoformat(), first, last, (last-first).days != 6))
112
+ return months, weeks
113
+
114
+
115
+ def build(root, cutoff, merchants, settlements, disputes):
116
+ months, weeks = periods(cutoff)
117
+ files, valid_dates, invalid_dates, first_seen = [], set(), set(), {}
118
+ physical = 0
119
+ folder = root / '340FRD'
120
+ if folder.is_dir():
121
+ for path in sorted(folder.iterdir()):
122
+ if not path.is_file() or '340FRD' not in path.name:
123
+ continue
124
+ data = path.read_bytes()
125
+ info = {'file': path.name, 'sha256': hashlib.sha256(data).hexdigest()}
126
+ match = FILENAME.fullmatch(path.name)
127
+ try:
128
+ if not match:
129
+ raise ValueError('filename_format')
130
+ created = dart_date(match[1]); info['date'] = created.isoformat()
131
+ if created > cutoff:
132
+ info['status'] = 'future_not_parsed'; files.append(info); continue
133
+ created, rows = parse_file(path.name, data)
134
+ info['status'] = 'valid'; valid_dates.add(created); physical += len(rows)
135
+ for row in rows:
136
+ first_seen[row] = min(created, first_seen.get(row, created))
137
+ except ValueError as exc:
138
+ info.update(status='invalid', error=str(exc))
139
+ if 'date' in info:
140
+ invalid_dates.add(date.fromisoformat(info['date']))
141
+ files.append(info)
142
+ sales = defaultdict(list)
143
+ audit = Counter()
144
+ for row in settlements:
145
+ if row.get('Settlement type') == 'Sale' and row.get('Settlement status') == 'Settled' and row.get('ARN') and row.get('MID'):
146
+ if date.fromisoformat(row['Settlement date'][:10]) <= cutoff:
147
+ if ' '.join((row.get('Provider') or '').split()).casefold() != 'worldpay - acquiring':
148
+ audit['unverified_settlement_provider'] += 1
149
+ continue
150
+ sales[row['ARN'], row['MID']].append(row)
151
+ fraud = []
152
+ unidentified_fraud = []
153
+ fraud_groups = defaultdict(list)
154
+ unmatched_amounts = defaultdict(Decimal)
155
+ observations = Counter(d.strftime('%Y-%m') for d in first_seen.values())
156
+ for row, observed in first_seen.items():
157
+ if dart_date(row[5]) > cutoff:
158
+ audit['transaction_after_cutoff'] += 1
159
+ continue
160
+ candidates = sales.get((row[10], row[1]), []) if row[10] and row[1] else []
161
+ if not candidates:
162
+ audit['unmatched'] += 1
163
+ unmatched_amounts[row[6]] += Decimal(row[7])
164
+ continue
165
+ if len(candidates) != 1:
166
+ audit['ambiguous'] += 1; continue
167
+ sale = candidates[0]
168
+ if not (sale.get('Transaction ID') or '').strip():
169
+ audit['missing_sale_identity'] += 1
170
+ unidentified_fraud.append((sale.get('_merchant') or sale.get('Sub merchant ID') or None, observed, 'visa_fraud', 'missing sale identity'))
171
+ continue
172
+ try:
173
+ agreement = (date.fromisoformat(sale['Timestamp'][:10]) == dart_date(row[5]) and
174
+ sale.get('Transaction amount currency', '').upper() == row[6] and
175
+ Decimal(sale.get('Transaction amount', '')) == Decimal(row[7]))
176
+ except (ValueError, InvalidOperation):
177
+ agreement = False
178
+ if not agreement:
179
+ audit['mismatch'] += 1; continue
180
+ merchant = sale.get('_merchant') or sale.get('Sub merchant ID')
181
+ if not merchant:
182
+ audit['missing_merchant'] += 1; continue
183
+ brand = {'visa': 'visa', 'mastercard': 'mastercard'}.get(brand_of(sale), 'other')
184
+ audit['attributed'] += 1
185
+ audit['attributed_' + brand] += 1
186
+ fraud.append((merchant, observed, brand))
187
+ fraud_groups[row[10], row[1]].append((merchant, observed, brand))
188
+ cases = defaultdict(list)
189
+ observed_dates = []
190
+ daudit = Counter()
191
+ for row in disputes:
192
+ d = date.fromisoformat(row['Chargeback date'][:10])
193
+ if d > cutoff:
194
+ continue
195
+ observed_dates.append(d)
196
+ provider = 'worldpay - acquiring' if verified_provider(row) else row.get('Provider')
197
+ key = (provider, row.get('MID'), row.get('Dispute ID'))
198
+ if not all(key):
199
+ daudit['missing_identity'] += 1; continue
200
+ cases[key].append((row, d))
201
+ rate_observed_dates = [date.fromisoformat(r['Chargeback date'][:10]) for r in disputes
202
+ if verified_provider(r) and date.fromisoformat(r['Chargeback date'][:10]) <= cutoff]
203
+ events, uncertain = [], []
204
+ for rows in cases.values():
205
+ mids = {r.get('_merchant') for r, _ in rows if r.get('_merchant')}
206
+ brands = {brand_of(r) for r, _ in rows}
207
+ conflict = len(mids) > 1 or len(brands) > 1
208
+ if len(mids) > 1:
209
+ daudit['conflicting_merchant_cases'] += 1
210
+ if len(brands) > 1:
211
+ daudit['conflicting_brand_cases'] += 1
212
+ if conflict:
213
+ for mid in mids:
214
+ for _, d in rows:
215
+ for brand in brands:
216
+ uncertain.extend((mid, d, k) for k in affected_series(brand))
217
+ continue
218
+ missing_merchant = [(r, d) for r, d in rows if not r.get('_merchant')]
219
+ if missing_merchant:
220
+ daudit['unattributed_snapshots'] += len(missing_merchant)
221
+ rows = [(r, d) for r, d in rows if r.get('_merchant')]
222
+ for kind in {stage(r) for r, _ in rows}:
223
+ selected = [(r, d) for r, d in rows if stage(r) == kind]
224
+ if kind is None:
225
+ daudit['unclassified_snapshots'] += len(selected)
226
+ for r, d in selected:
227
+ for mid in mids:
228
+ uncertain.extend((mid, d, k) for k in affected_series(brand_of(r)))
229
+ continue
230
+ events.append((next(iter(mids)), min(d for _, d in selected), kind))
231
+ # Rates use opening evidence independently of the existing observed-stage counts.
232
+ rate_events, rate_uncertain = [], unidentified_fraud[:]
233
+ for row in disputes:
234
+ d = date.fromisoformat(row['Chargeback date'][:10])
235
+ if d <= cutoff and (not row.get('Provider') or not row.get('MID') or not row.get('Dispute ID') or not row.get('_merchant')):
236
+ rate_uncertain.append((row.get('_merchant') or None, d, brand_of(row), 'missing case identity or merchant'))
237
+ for rows in cases.values():
238
+ mids = {r.get('_merchant') for r, _ in rows if r.get('_merchant')}
239
+ brands = {brand_of(r) for r, _ in rows}
240
+ if not verified_provider(rows[0][0]) or len(mids) != 1 or len(brands) != 1 or any(not r.get('_merchant') for r, _ in rows):
241
+ candidate_mids = mids | ({None} if any(not r.get('_merchant') for r, _ in rows) else set())
242
+ for mid in candidate_mids:
243
+ for brand in brands:
244
+ for _, d in rows:
245
+ rate_uncertain.append((mid, d, brand, 'unverified provider or conflicting or missing case attribution'))
246
+ continue
247
+ mid, brand = next(iter(mids)), next(iter(brands))
248
+ if brand not in ('visa', 'mastercard'):
249
+ for _, d in rows:
250
+ rate_uncertain.append((mid, d, brand, 'unknown case brand'))
251
+ continue
252
+ requests = [(r, d) for r, d in rows if (r.get('Lifecycle state'), r.get('Original cycle')) == ('1st chargeback', 'First CB Request')]
253
+ pre = [(r, d) for r, d in rows if stage(r) == 'visa_predispute']
254
+ if pre:
255
+ rate_events.append((mid, min(d for _, d in pre), 'visa_predispute'))
256
+ formal = [(r, d) for r, d in rows if stage(r) in ('visa_dispute', 'mastercard_chargeback')]
257
+ if requests:
258
+ opening = min(d for _, d in requests)
259
+ key = 'visa_dispute' if brand == 'visa' else 'mastercard_chargeback'
260
+ rate_events.append((mid, opening, key))
261
+ for r, d in formal:
262
+ if d < opening:
263
+ rate_uncertain.append((mid, d, key, 'earlier status may change opening month'))
264
+ rate_uncertain.append((mid, opening, key, 'earlier status may change opening month'))
265
+ if brand == 'mastercard':
266
+ rate_uncertain.extend([(mid, d, 'mastercard_efm', 'earlier status may change opening month'),
267
+ (mid, opening, 'mastercard_efm', 'earlier status may change opening month')])
268
+ if brand == 'mastercard':
269
+ reasons = {(r.get('Reason code') or '').strip() for r, _ in rows}
270
+ if len(reasons) != 1 or not next(iter(reasons)):
271
+ rate_uncertain.append((mid, opening, 'mastercard_efm', 'missing or conflicting reason code'))
272
+ elif next(iter(reasons)) in ('4837', '4863'):
273
+ rate_events.append((mid, opening, 'mastercard_efm'))
274
+ elif formal:
275
+ for _, d in formal:
276
+ rate_uncertain.append((mid, d, 'visa_dispute' if brand == 'visa' else 'mastercard_chargeback', 'formal opening not verified'))
277
+ if brand == 'mastercard':
278
+ rate_uncertain.append((mid, d, 'mastercard_efm', 'formal opening not verified'))
279
+ for r, d in rows:
280
+ if stage(r) is None:
281
+ rate_uncertain.append((mid, d, brand, 'unclassified case stage'))
282
+ variants = defaultdict(list)
283
+ for row, observed in first_seen.items():
284
+ if row[10] and row[1] and dart_date(row[5]) <= cutoff:
285
+ variants[row[10], row[1]].append(observed)
286
+ for identity, dates in variants.items():
287
+ if len(dates) > 1:
288
+ for mid, _, brand in fraud_groups.get(identity, []):
289
+ if brand == 'visa':
290
+ for d in dates:
291
+ rate_uncertain.append((mid, d, 'visa_fraud', 'multiple distinct fraud records for ARN and MID'))
292
+
293
+ rate_sales, undated_sales, unidentified_sales = [], [], []
294
+ for row in settlements:
295
+ if row.get('Settlement type') != 'Sale' or row.get('Settlement status') != 'Settled':
296
+ continue
297
+ if ' '.join((row.get('Provider') or '').split()).casefold() != 'worldpay - acquiring':
298
+ continue
299
+ try:
300
+ settled = date.fromisoformat(row['Settlement date'][:10])
301
+ except (KeyError, ValueError, TypeError):
302
+ settled = None
303
+ if settled is not None and settled > cutoff:
304
+ continue
305
+ try:
306
+ stamp = datetime.fromisoformat(row['Timestamp'])
307
+ if stamp.tzinfo is not None:
308
+ raise ValueError('zoned transaction timestamp')
309
+ transaction = stamp.date()
310
+ except (KeyError, ValueError, TypeError):
311
+ undated_sales.append((row.get('_merchant') or row.get('Sub merchant ID') or None, brand_of(row)))
312
+ continue
313
+ if settled is None:
314
+ undated_sales.append((row.get('_merchant') or row.get('Sub merchant ID') or None, brand_of(row)))
315
+ continue
316
+ if transaction <= cutoff and settled <= cutoff:
317
+ sale = (row.get('_merchant') or row.get('Sub merchant ID') or None, transaction,
318
+ brand_of(row), (row.get('Transaction source') or '').strip().casefold())
319
+ if not (row.get('Transaction ID') or '').strip():
320
+ unidentified_sales.append(sale)
321
+ else:
322
+ rate_sales.append(sale)
323
+
324
+ sales_by_month = defaultdict(list)
325
+ for sale in rate_sales:
326
+ sales_by_month[sale[1].strftime('%Y-%m')].append(sale)
327
+
328
+ def rate_point(first, last, mid, key, weekly=False):
329
+ brand = 'mastercard' if key in ('mastercard_chargeback', 'mastercard_efm') else 'visa'
330
+ if brand == 'mastercard':
331
+ end = first - timedelta(days=1)
332
+ start = end.replace(day=1)
333
+ else:
334
+ start, end = first, last
335
+ formulas = {'visa_predispute': 'observed Visa RDR cases / same-month Visa sales (descriptive; excluded from VAMP)',
336
+ 'visa_dispute': 'Visa First CB Request cases / same-month Visa sales',
337
+ 'mastercard_chargeback': 'Mastercard First CB Request cases / prior-month Mastercard sales',
338
+ 'visa': 'attributed Visa fraud report records / same-month Visa sales',
339
+ 'mastercard_efm': 'Mastercard First CB Request cases with reason 4837 or 4863 / prior-month ecommerce Mastercard sales'}
340
+ date_bases = {'visa': 'User-confirmed DART file creation date as fraud report date',
341
+ 'visa_predispute': 'Earliest observed RDR date',
342
+ 'visa_dispute': 'First CB Request chargeback date',
343
+ 'mastercard_chargeback': 'First CB Request chargeback date',
344
+ 'mastercard_efm': 'Mastercard formal opening date (First CB Request chargeback date)'}
345
+ detail = {'numerator': None, 'denominator': None, 'denominator_start': start.isoformat(),
346
+ 'denominator_end': end.isoformat(), 'status': 'unresolved', 'reason': None, 'formula': formulas[key],
347
+ 'date_basis': date_bases[key]}
348
+ if weekly:
349
+ detail.update(denominator_start=None, denominator_end=None, status='not_applicable')
350
+ detail['reason'] = 'Network monitoring programs are monthly; weekly rates are not calculated.'
351
+ return None, detail
352
+ source_observed = any(first <= d <= last for d in (valid_dates if key == 'visa' else rate_observed_dates))
353
+ ambiguity_key = 'visa_fraud' if key == 'visa' else brand
354
+ ambiguous = any((m is None or m == mid) and first <= d <= last and
355
+ (b == ambiguity_key or b == key or b not in ('visa', 'mastercard', 'visa_fraud', 'visa_predispute', 'visa_dispute', 'mastercard_chargeback', 'mastercard_efm'))
356
+ for m, d, b, _ in rate_uncertain)
357
+ if key == 'visa':
358
+ numerator = sum(m == mid and first <= d <= last and b == 'visa' for m, d, b in fraud)
359
+ else:
360
+ numerator = sum(m == mid and first <= d <= last and k == key for m, d, k in rate_events)
361
+ if source_observed and not ambiguous:
362
+ detail['numerator'] = numerator
363
+ candidates = [(m, d, b, channel) for m, d, b, channel in sales_by_month[start.strftime('%Y-%m')] if start <= d <= end]
364
+ scoped = [(m, d, b, channel) for m, d, b, channel in candidates if m == mid and b == brand]
365
+ unknown_scope = any((m is None or m == mid) and (b == brand or b not in ('visa', 'mastercard')) and
366
+ (m is None or b not in ('visa', 'mastercard')) for m, d, b, channel in candidates)
367
+ unknown_date = any((m is None or m == mid) and (b == brand or b not in ('visa', 'mastercard'))
368
+ for m, b in undated_sales)
369
+ unknown_identity = any(start <= d <= end and (m is None or m == mid) and
370
+ (b == brand or b not in ('visa', 'mastercard'))
371
+ for m, d, b, _ in unidentified_sales)
372
+ unknown_channel = key == 'mastercard_efm' and any(channel not in ('ecomm', 'pos', 'moto') for _, _, _, channel in scoped)
373
+ if scoped and not unknown_scope and not unknown_channel and not unknown_date and not unknown_identity:
374
+ detail['denominator'] = sum(key != 'mastercard_efm' or channel == 'ecomm' for _, _, _, channel in scoped)
375
+ if ambiguous:
376
+ detail['reason'] = 'Case or fraud event identity, opening, attribution or reason is unresolved.'
377
+ elif not source_observed:
378
+ detail['reason'] = 'No source observations in this month.'
379
+ elif unknown_scope or unknown_channel or unknown_date or unknown_identity:
380
+ detail['reason'] = 'Sale identity, attribution, brand, transaction date or ecommerce channel is incomplete.'
381
+ elif detail['denominator'] is None:
382
+ detail['reason'] = 'No observed scoped sales in the denominator month.'
383
+ elif not numerator or not detail['denominator']:
384
+ detail['reason'] = 'Numerator or denominator is zero; no rate point under the point contract.'
385
+ else:
386
+ detail['status'] = 'observed_extract_estimate'
387
+ detail['reason'] = 'Observed source coverage is incomplete or unverified; not an official network assessment.'
388
+ return numerator / detail['denominator'], detail
389
+ return None, detail
390
+
391
+ def grid(period_list, events, mid, keys, kind):
392
+ out = []
393
+ for label, first, last, partial in period_list:
394
+ if kind == 'fraud':
395
+ observed = any(first <= d <= last for d in valid_dates)
396
+ coverage = {'status': 'supplied_valid_files' if observed else 'no_valid_files',
397
+ 'valid_file_dates': sum(first <= d <= last for d in valid_dates),
398
+ 'invalid_file_dates': sum(first <= d <= last for d in invalid_dates),
399
+ 'complete': False}
400
+ else:
401
+ observed = any(first <= d <= last for d in observed_dates)
402
+ coverage = 'observedexports' if observed else 'no_observations'
403
+ vals = {k: sum(m == mid and first <= d <= last and s == k for m, d, s in events)
404
+ if observed and (kind != 'dispute' or not any(
405
+ m == mid and first <= d <= last and s == k for m, d, s in uncertain))
406
+ else None for k in keys}
407
+ rate_keys = ['visa', 'mastercard_efm'] if kind == 'fraud' else ['visa_predispute', 'visa_dispute', 'mastercard_chargeback']
408
+ computed = {k: rate_point(first, last, mid, k, period_list is weeks) for k in rate_keys}
409
+ out.append({'label': label, 'start': first.isoformat(), 'end': last.isoformat(), 'values': vals, 'coverage': coverage, 'partial': partial,
410
+ 'rates': {k: v[0] for k, v in computed.items()}, 'rate_details': {k: v[1] for k, v in computed.items()}})
411
+ return out
412
+ result = []
413
+ for merchant in merchants:
414
+ mid = merchant['id'] or ''
415
+ result.append({'id': merchant['id'], 'name': merchant['name'],
416
+ 'disputes': {'monthly': grid(months, events, mid, ['visa_predispute', 'visa_dispute', 'mastercard_chargeback'], 'dispute'),
417
+ 'weekly': grid(weeks, events, mid, ['visa_predispute', 'visa_dispute', 'mastercard_chargeback'], 'dispute'),
418
+ 'basis': 'observed cases at earliest supplied chargeback date per stage, not verified openings',
419
+ 'limitations': 'Observed exports only; escalation may appear in both stages; reversals do not subtract.'},
420
+ 'fraud': {'monthly': grid(months, fraud, mid, ['visa', 'mastercard', 'other'], 'fraud'),
421
+ 'weekly': grid(weeks, fraud, mid, ['visa', 'mastercard', 'other'], 'fraud'),
422
+ 'basis': 'DART file creation date as report date (user-confirmed); earliest report date for exact repeats',
423
+ 'limitations': 'Exact records, not unique events; incomplete supplied feed; unmatched records excluded.',
424
+ 'status': 'supplied_valid_files' if valid_dates else 'no_valid_files',
425
+ 'reason': None if valid_dates else 'No eligible valid fraud files supplied.'},
426
+ 'vamp': {'monthly': [{'label': l, 'start': a.isoformat(), 'end': b.isoformat(), 'values': {'vamp_ratio': None}, 'partial': p} for l, a, b, p in months],
427
+ 'status': 'unavailable', 'reason': '缺少经核实的 TC15/TC05 处理日、排除标记及网络事件范围',
428
+ 'eligibility_missing': ['TC15/TC05 processing date', 'eligibility and exclusions', 'network event scope', 'event identity', 'complete coverage']}})
429
+ return result, {'files': files, 'physical_rows': physical, 'distinct_exact_rows': len(first_seen),
430
+ 'fraud_attribution': dict(audit), 'unmatched_amounts_by_currency': {k: str(v) for k, v in sorted(unmatched_amounts.items())},
431
+ 'first_observations_by_month': dict(sorted(observations.items())),
432
+ 'fraud_feed_status': 'supplied_valid_files' if valid_dates else 'no_valid_files',
433
+ 'fraud_report_date_policy': 'DART file creation date as report date (user-confirmed); earliest report date for exact repeats',
434
+ 'rate_basis': 'Network-style estimates from observed extracts, not official program assessments; source calendar labels, timezone unverified; unmatched fraud records excluded; source coverage unverified.', 'disputes': dict(daudit),
435
+ 'observed_dispute_stage_cases': len(events), 'coverage': 'supplied exports only; continuity unverified'}
@@ -6,9 +6,9 @@ description: >
6
6
  (analytics, tenant and payment account IDs, support-email domains,
7
7
  templates, DNS, mail, certificates, registration), reverse pivots, scored
8
8
  links, an entity chain, and a watchlist for the operator's next domains.
9
- Not transaction data (tld-detection) or fraud rules (fraud-analysis).
9
+ Not transaction data (tld-detection) or fraud rules (fraud-detection).
10
10
  disable-model-invocation: true
11
- release-version: 0.44.1
11
+ release-version: 0.45.1
12
12
  ---
13
13
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
14
14
  # Similar Domain
@@ -132,7 +132,12 @@ open and the report lists the unrun pivots.
132
132
  ### 7 — Report
133
133
 
134
134
  [references/report-template.md](references/report-template.md). Terms:
135
- [references/terminology.md](references/terminology.md).
135
+ [references/terminology.md](references/terminology.md). Read
136
+ `{house-style baseDir}/references/analytical-reporting.md` and its required references,
137
+ then use `{write-report baseDir}/SKILL.md` with the frozen cluster and domain template.
138
+ If a required skill or reference is unavailable, report the blocker. Do not rerun
139
+ fingerprinting or scoring for the writing pass. Give the reviewer the shared entrypoint,
140
+ its required references, the domain template and the sanity check.
136
141
 
137
142
  ```text
138
143
  reports/similar-domain/<seed>_cluster.md
@@ -163,7 +168,7 @@ Track a task plan. One item is `in_progress` at a time.
163
168
  |---|---|
164
169
  | Related domains, mirrors, sister sites, who runs a website, merchant-site background | `similar-domain` |
165
170
  | Anything inside a transaction file: upstream streams behind a descriptor, wallet currency, preset menus, frozen-rate batches, retail constants, currency-amount ladders | `tld-detection` (`scripts/detect_streams.py`, then the ladder run) |
166
- | Supervised fraud labels, holdout precision/recall, rule packages | `fraud-analysis` |
171
+ | Transaction-level fraud rule discovery and evidence-qualified metrics | `fraud-detection` |
167
172
  | Volume, journey, auth rate, decline mix | `payment-analysis` |
168
173
 
169
174
  A domain cluster from this skill can feed a merchant-risk decision; it is not