Add EBITDA bridge macro + XML name-cleanup script; harden CleanDefinedNames
- BuildEbitdaBridge.bas: waterfall bridge tab (2026E->2027 AOP) from Slide 13 - clean_names_xml.py: strip junk defined names via direct XML surgery (Excel save corrupts this workbook's query tables/pivot caches) - CleanDefinedNames.bas: skip all _xl* reserved names; copy survivors to clipboard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c0d4d3ac84
commit
33b7f3da74
179
BuildEbitdaBridge.bas
Normal file
179
BuildEbitdaBridge.bas
Normal file
@ -0,0 +1,179 @@
|
||||
Attribute VB_Name = "BuildEbitdaBridge"
|
||||
Option Explicit
|
||||
|
||||
' ============================================================
|
||||
' BuildEbitdaBridge
|
||||
' Builds an EBITDA bridge (waterfall) tab walking Growscape
|
||||
' Management Adj. EBITDA from 2026E (Slide 13 col Q) to
|
||||
' 2027 AOP (Slide 13 col V), decomposed by P&L driver line.
|
||||
'
|
||||
' Bars are LIVE formulas referencing 'Slide 13', so the tab
|
||||
' updates on recalc. Stacked-column technique (Base/Down/Up/
|
||||
' Total) for version-proof formatting. Re-runnable: rebuilds
|
||||
' the sheet each time.
|
||||
'
|
||||
' EBITDA(42) = NetSales(23) - Materials(25) - Labor(26)
|
||||
' - VarOH(27) - Fixed(31) - Distribution(35)
|
||||
' - Freight(36) - SG&A(40) - Other(41)
|
||||
' Bar = +d(NetSales) ; -d(each cost). Sum = V42 - Q42.
|
||||
' ============================================================
|
||||
Sub BuildEbitdaBridge()
|
||||
Const SRC As String = "Slide 13" ' source P&L tab
|
||||
Const OUT As String = "EBITDA Bridge" ' output tab
|
||||
Const C1 As String = "Q" ' 2026E (start) column on SRC
|
||||
Const C2 As String = "V" ' 2027 AOP (end) column on SRC
|
||||
|
||||
Dim wb As Workbook: Set wb = ThisWorkbook
|
||||
Dim ws As Worksheet
|
||||
|
||||
' --- guard: source must exist ---
|
||||
On Error Resume Next
|
||||
Set ws = wb.Worksheets(SRC)
|
||||
On Error GoTo 0
|
||||
If ws Is Nothing Then
|
||||
MsgBox "Sheet '" & SRC & "' not found.", vbExclamation: Exit Sub
|
||||
End If
|
||||
|
||||
' --- (re)create output sheet ---
|
||||
Application.DisplayAlerts = False
|
||||
On Error Resume Next
|
||||
wb.Worksheets(OUT).Delete
|
||||
On Error GoTo 0
|
||||
Application.DisplayAlerts = True
|
||||
Set ws = wb.Worksheets.Add(After:=wb.Worksheets(wb.Worksheets.Count))
|
||||
ws.Name = OUT
|
||||
|
||||
' --- driver definition: label, SRC row, sign ( +1 sales, -1 cost ) ---
|
||||
Dim lbl() As Variant, rw() As Variant, sgn() As Variant
|
||||
lbl = Array("Net Sales", "Materials", "Labor", "Var Overhead", _
|
||||
"Fixed Costs", "Distribution", "Freight", "SG&A", "Other")
|
||||
rw = Array(23, 25, 26, 27, 31, 35, 36, 40, 41)
|
||||
sgn = Array(1, -1, -1, -1, -1, -1, -1, -1, -1)
|
||||
|
||||
' --- header ---
|
||||
ws.Range("B2").Value = "EBITDA Bridge - 2026E to 2027 AOP"
|
||||
ws.Range("B2").Font.Bold = True: ws.Range("B2").Font.Size = 14
|
||||
ws.Range("B3").Value = "Growscape, Management Adj. EBITDA ($ in 000s)"
|
||||
ws.Range("B3").Font.Italic = True
|
||||
|
||||
Dim hRow As Long: hRow = 5
|
||||
ws.Cells(hRow, 2).Value = "Step"
|
||||
ws.Cells(hRow, 3).Value = "Value"
|
||||
ws.Cells(hRow, 4).Value = "Base"
|
||||
ws.Cells(hRow, 5).Value = "Decrease"
|
||||
ws.Cells(hRow, 6).Value = "Increase"
|
||||
ws.Cells(hRow, 7).Value = "Total"
|
||||
ws.Cells(hRow, 8).Value = "Cumulative"
|
||||
ws.Range(ws.Cells(hRow, 2), ws.Cells(hRow, 8)).Font.Bold = True
|
||||
|
||||
Dim r As Long, i As Long, prevH As Long
|
||||
Dim firstData As Long, lastData As Long
|
||||
firstData = hRow + 1
|
||||
|
||||
' ---- START anchor: 2026E EBITDA (Q42) ----
|
||||
r = firstData
|
||||
ws.Cells(r, 2).Value = "2026E EBITDA"
|
||||
ws.Cells(r, 3).Formula = "='" & SRC & "'!" & C1 & "42"
|
||||
ws.Cells(r, 4).Value = 0 ' Base
|
||||
ws.Cells(r, 5).Value = 0 ' Decrease
|
||||
ws.Cells(r, 6).Value = 0 ' Increase
|
||||
ws.Cells(r, 7).Formula = "=C" & r ' Total
|
||||
ws.Cells(r, 8).Formula = "=C" & r ' Cumulative
|
||||
|
||||
' ---- driver steps ----
|
||||
For i = LBound(lbl) To UBound(lbl)
|
||||
r = firstData + 1 + i
|
||||
prevH = r - 1
|
||||
ws.Cells(r, 2).Value = lbl(i)
|
||||
' delta contribution to EBITDA, signed
|
||||
If sgn(i) = 1 Then
|
||||
ws.Cells(r, 3).Formula = "='" & SRC & "'!" & C2 & rw(i) & "-'" & SRC & "'!" & C1 & rw(i)
|
||||
Else
|
||||
ws.Cells(r, 3).Formula = "=-('" & SRC & "'!" & C2 & rw(i) & "-'" & SRC & "'!" & C1 & rw(i) & ")"
|
||||
End If
|
||||
ws.Cells(r, 4).Formula = "=IF(C" & r & ">=0,H" & prevH & ",H" & prevH & "+C" & r & ")" ' Base
|
||||
ws.Cells(r, 5).Formula = "=IF(C" & r & "<0,-C" & r & ",0)" ' Decrease
|
||||
ws.Cells(r, 6).Formula = "=IF(C" & r & ">=0,C" & r & ",0)" ' Increase
|
||||
ws.Cells(r, 7).Value = 0 ' Total
|
||||
ws.Cells(r, 8).Formula = "=H" & prevH & "+C" & r ' Cumulative
|
||||
Next i
|
||||
|
||||
' ---- END anchor: 2027 AOP EBITDA (V42) ----
|
||||
r = r + 1
|
||||
lastData = r
|
||||
ws.Cells(r, 2).Value = "2027 AOP EBITDA"
|
||||
ws.Cells(r, 3).Formula = "='" & SRC & "'!" & C2 & "42"
|
||||
ws.Cells(r, 4).Value = 0
|
||||
ws.Cells(r, 5).Value = 0
|
||||
ws.Cells(r, 6).Value = 0
|
||||
ws.Cells(r, 7).Formula = "=C" & r
|
||||
ws.Cells(r, 8).Formula = "=C" & r
|
||||
|
||||
' ---- tie-out check: cumulative before end anchor must equal V42 ----
|
||||
ws.Cells(lastData + 2, 2).Value = "Tie-out (should be 0):"
|
||||
ws.Cells(lastData + 2, 3).Formula = "=H" & (lastData - 1) & "-C" & lastData
|
||||
ws.Cells(lastData + 2, 2).Font.Italic = True
|
||||
|
||||
' ---- number formats ----
|
||||
ws.Range(ws.Cells(firstData, 3), ws.Cells(lastData, 8)).NumberFormat = "#,##0;(#,##0)"
|
||||
ws.Cells(lastData + 2, 3).NumberFormat = "#,##0;(#,##0)"
|
||||
ws.Columns("B").ColumnWidth = 18
|
||||
ws.Columns("C:H").ColumnWidth = 11
|
||||
|
||||
' ============================================================
|
||||
' Chart: stacked column waterfall
|
||||
' ============================================================
|
||||
Dim co As ChartObject, ch As Chart
|
||||
Set co = ws.ChartObjects.Add(Left:=ws.Cells(5, 10).Left, Top:=ws.Cells(5, 10).Top, _
|
||||
Width:=620, Height:=340)
|
||||
Set ch = co.Chart
|
||||
ch.ChartType = xlColumnStacked
|
||||
|
||||
Dim catRng As Range, baseRng As Range, decRng As Range, incRng As Range, totRng As Range
|
||||
Set catRng = ws.Range(ws.Cells(firstData, 2), ws.Cells(lastData, 2))
|
||||
Set baseRng = ws.Range(ws.Cells(firstData, 4), ws.Cells(lastData, 4))
|
||||
Set decRng = ws.Range(ws.Cells(firstData, 5), ws.Cells(lastData, 5))
|
||||
Set incRng = ws.Range(ws.Cells(firstData, 6), ws.Cells(lastData, 6))
|
||||
Set totRng = ws.Range(ws.Cells(firstData, 7), ws.Cells(lastData, 7))
|
||||
|
||||
Do While ch.SeriesCollection.Count > 0
|
||||
ch.SeriesCollection(1).Delete
|
||||
Loop
|
||||
|
||||
Dim s As Series
|
||||
' 1) Base (invisible riser)
|
||||
Set s = ch.SeriesCollection.NewSeries
|
||||
s.Name = "Base": s.Values = baseRng: s.XValues = catRng
|
||||
s.Format.Fill.Visible = msoFalse: s.Format.Line.Visible = msoFalse
|
||||
' 2) Decrease (red)
|
||||
Set s = ch.SeriesCollection.NewSeries
|
||||
s.Name = "Decrease": s.Values = decRng: s.XValues = catRng
|
||||
s.Format.Fill.ForeColor.RGB = RGB(192, 0, 0)
|
||||
s.HasDataLabels = True: s.DataLabels.NumberFormat = "#,##0;(#,##0)"
|
||||
' 3) Increase (green)
|
||||
Set s = ch.SeriesCollection.NewSeries
|
||||
s.Name = "Increase": s.Values = incRng: s.XValues = catRng
|
||||
s.Format.Fill.ForeColor.RGB = RGB(112, 173, 71)
|
||||
s.HasDataLabels = True: s.DataLabels.NumberFormat = "#,##0;(#,##0)"
|
||||
' 4) Total (dark blue) — start/end anchors
|
||||
Set s = ch.SeriesCollection.NewSeries
|
||||
s.Name = "Total": s.Values = totRng: s.XValues = catRng
|
||||
s.Format.Fill.ForeColor.RGB = RGB(31, 78, 121)
|
||||
s.HasDataLabels = True: s.DataLabels.NumberFormat = "#,##0;(#,##0)"
|
||||
|
||||
ch.ChartGroups(1).GapWidth = 30
|
||||
ch.ChartGroups(1).Overlap = 100
|
||||
ch.HasTitle = True
|
||||
ch.ChartTitle.Text = "EBITDA Bridge: 2026E to 2027 AOP (Growscape, $000s)"
|
||||
ch.HasLegend = False
|
||||
|
||||
' drop the "Base" out of any axis clutter; keep gridlines light
|
||||
On Error Resume Next
|
||||
ch.Axes(xlValue).MajorGridlines.Format.Line.ForeColor.RGB = RGB(217, 217, 217)
|
||||
On Error GoTo 0
|
||||
|
||||
ws.Range("B2").Select
|
||||
MsgBox "EBITDA Bridge built." & vbCrLf & _
|
||||
"Check the tie-out cell (C" & (lastData + 2) & ") = 0." & vbCrLf & _
|
||||
"Recalc (Ctrl+Alt+F9) if values look stale.", vbInformation
|
||||
End Sub
|
||||
@ -56,8 +56,12 @@ Sub CleanDefinedNames()
|
||||
|
||||
If Len(nmName) > 0 And keep.Exists(nmName) Then
|
||||
kept = kept + 1
|
||||
ElseIf InStr(1, nmName, "_xlnm.", vbTextCompare) > 0 Then
|
||||
' Built-in print areas / titles - leave alone
|
||||
ElseIf InStr(1, nmName, "_xl", vbTextCompare) = 1 Then
|
||||
' Excel-reserved system names - leave alone:
|
||||
' _xlnm. = built-in print areas/titles
|
||||
' _xlfn. = future-function markers (XLOOKUP, SWITCH, SUMIFS, ...)
|
||||
' _xlcn. = data-model / linked-table connection names
|
||||
' Deleting these breaks formulas / the data model.
|
||||
skipped = skipped + 1
|
||||
Else
|
||||
' Corrupt names (=#NAME?, illegal chars) make Excel re-validate
|
||||
@ -84,13 +88,44 @@ Sub CleanDefinedNames()
|
||||
Application.Calculation = xlCalculationAutomatic
|
||||
Application.ScreenUpdating = True
|
||||
|
||||
' --- collect surviving names (name <TAB> refers-to) for the clipboard ---
|
||||
Dim survivors As String, nn As Name, rt As String
|
||||
survivors = "Name" & vbTab & "RefersTo" & vbCrLf
|
||||
For Each nn In ThisWorkbook.Names
|
||||
rt = ""
|
||||
On Error Resume Next
|
||||
rt = nn.RefersTo
|
||||
On Error GoTo 0
|
||||
survivors = survivors & nn.Name & vbTab & rt & vbCrLf
|
||||
Next nn
|
||||
Dim copied As Boolean
|
||||
copied = PutOnClipboard(survivors)
|
||||
|
||||
report = "Done." & vbCrLf & _
|
||||
"Deleted: " & deleted & vbCrLf & _
|
||||
"Kept (whitelist): " & kept & vbCrLf & _
|
||||
"Skipped (_xlnm built-ins): " & skipped & vbCrLf & _
|
||||
"Failed (corrupt - need XML fix): " & failed & vbCrLf & _
|
||||
"Skipped (_xl* system names): " & skipped & vbCrLf & _
|
||||
"Failed: " & failed & vbCrLf & _
|
||||
"Remaining names: " & ThisWorkbook.Names.Count
|
||||
If failed > 0 Then report = report & vbCrLf & vbCrLf & _
|
||||
"First few that failed:" & vbCrLf & failedList
|
||||
report = report & vbCrLf & vbCrLf & _
|
||||
IIf(copied, "Remaining names copied to clipboard (paste anywhere).", _
|
||||
"(Could not access clipboard - see Immediate window.)")
|
||||
If Not copied Then Debug.Print survivors
|
||||
MsgBox report, vbInformation, "CleanDefinedNames"
|
||||
End Sub
|
||||
|
||||
' Put text on the Windows clipboard without a library reference,
|
||||
' via the MSForms.DataObject class GUID. Returns True on success.
|
||||
Private Function PutOnClipboard(ByVal s As String) As Boolean
|
||||
On Error GoTo fail
|
||||
Dim dobj As Object
|
||||
Set dobj = CreateObject("new:{1C3B4210-F441-11CE-B9EA-00AA006B1A69}")
|
||||
dobj.SetText s
|
||||
dobj.PutInClipboard
|
||||
PutOnClipboard = True
|
||||
Exit Function
|
||||
fail:
|
||||
PutOnClipboard = False
|
||||
End Function
|
||||
|
||||
100
clean_names_xml.py
Normal file
100
clean_names_xml.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""
|
||||
clean_names_xml.py -- strip junk defined names from an .xlsx via direct XML
|
||||
surgery (NOT via Excel).
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
The "Segment Financials" workbook accumulated ~13k junk defined names (Bloomberg
|
||||
BLPH*, SAP BEx*, Lotus ___PRN2/__123Graph, etc.). Deleting them with a VBA macro
|
||||
and then letting EXCEL SAVE repeatedly corrupted the file: Excel's save
|
||||
garbage-collected dependent parts (first the pivotCacheRecords, then
|
||||
xl/connections.xml -> orphaned the TB/SalesData query tables). That workbook is a
|
||||
web of Power Query outputs (TB & SalesData are query tables), connections, the
|
||||
data model (_xlcn.LinkedTable_*), external links, and pivot caches.
|
||||
|
||||
This script edits ONLY xl/workbook.xml's <definedNames> block and copies every
|
||||
other part byte-for-byte into a new file. Excel never re-saves, so nothing gets
|
||||
garbage-collected. Result opens clean.
|
||||
|
||||
WHAT IT KEEPS
|
||||
-------------
|
||||
* names in KEEP (your real user-defined names)
|
||||
* anything starting with _xl (Excel-reserved: _xlnm.* print/filter,
|
||||
_xlcn.* data-model connections, _xlpm.*, _xludf.*, ...)
|
||||
* anything starting with ExternalData (query-table external-data ranges)
|
||||
* any name REFERENCED elsewhere in the package (worksheets, tables,
|
||||
queryTables, charts, pivotTables, externalLinks, connections) -- so we
|
||||
never orphan a feature that points at a name.
|
||||
Everything else is dropped.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python clean_names_xml.py SRC.xlsx OUT.xlsx [Name1 Name2 ...]
|
||||
# if no names given, defaults to the Segment Financials trio below.
|
||||
Non-destructive: reads SRC, writes a NEW OUT; never touches SRC. Close OUT in
|
||||
Excel before re-running (Windows file lock).
|
||||
"""
|
||||
import zipfile, re, os, sys
|
||||
|
||||
DEFAULT_KEEP = {"Report_Date", "Value_Base", "FSPR_Date"}
|
||||
|
||||
|
||||
def clean(src, out, keep):
|
||||
zin = zipfile.ZipFile(src, "r")
|
||||
wb = zin.read("xl/workbook.xml").decode("utf-8")
|
||||
|
||||
m = re.search(r"<definedNames>.*?</definedNames>", wb, re.S)
|
||||
if not m:
|
||||
print("No <definedNames> block found - nothing to do.")
|
||||
return
|
||||
block = m.group(0)
|
||||
entries = re.findall(r"<definedName\b[^>]*>.*?</definedName>", block, re.S)
|
||||
print("definedName entries found:", len(entries))
|
||||
|
||||
# names REFERENCED anywhere structural (not workbook.xml, not bulk cell text)
|
||||
tok = re.compile(r"[A-Za-z_\\][A-Za-z0-9_.\\]*")
|
||||
referenced = set()
|
||||
SCAN = ("xl/worksheets/", "xl/charts/", "xl/pivotTables/",
|
||||
"xl/tables/", "xl/queryTables/", "xl/externalLinks/")
|
||||
for nm in zin.namelist():
|
||||
if nm.endswith(".xml") and nm != "xl/workbook.xml" \
|
||||
and (nm.startswith(SCAN) or "connections" in nm):
|
||||
referenced |= set(tok.findall(zin.read(nm).decode("utf-8", "ignore")))
|
||||
|
||||
kept, dropped, kept_ref = [], 0, []
|
||||
for e in entries:
|
||||
mm = re.search(r'name="([^"]*)"', e)
|
||||
name = mm.group(1) if mm else ""
|
||||
if (name in keep or name.startswith("_xl")
|
||||
or name.startswith("ExternalData") or name in referenced):
|
||||
kept.append(e)
|
||||
if name not in keep and not name.startswith("_xl"):
|
||||
kept_ref.append(name)
|
||||
else:
|
||||
dropped += 1
|
||||
print("keeping:", len(kept), "| dropping:", dropped)
|
||||
print("kept because referenced/external:", sorted(set(kept_ref)))
|
||||
|
||||
wb_new = wb[:m.start()] + "<definedNames>" + "".join(kept) + "</definedNames>" + wb[m.end():]
|
||||
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
zout = zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED)
|
||||
for item in zin.infolist():
|
||||
data = wb_new.encode("utf-8") if item.filename == "xl/workbook.xml" else zin.read(item.filename)
|
||||
zi = zipfile.ZipInfo(item.filename, date_time=item.date_time)
|
||||
zi.compress_type = item.compress_type
|
||||
zi.external_attr = item.external_attr
|
||||
zout.writestr(zi, data)
|
||||
zout.close()
|
||||
zin.close()
|
||||
print("WROTE:", out, "| size:", os.path.getsize(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: python clean_names_xml.py SRC.xlsx OUT.xlsx [KeepName ...]")
|
||||
sys.exit(1)
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
keep = set(sys.argv[3:]) if len(sys.argv) > 3 else DEFAULT_KEEP
|
||||
clean(src, out, keep)
|
||||
Loading…
Reference in New Issue
Block a user