diff --git a/BuildEbitdaBridge.bas b/BuildEbitdaBridge.bas new file mode 100644 index 0000000..8006bd5 --- /dev/null +++ b/BuildEbitdaBridge.bas @@ -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 diff --git a/CleanDefinedNames.bas b/CleanDefinedNames.bas index 40d765c..bb3f251 100644 --- a/CleanDefinedNames.bas +++ b/CleanDefinedNames.bas @@ -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 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 diff --git a/clean_names_xml.py b/clean_names_xml.py new file mode 100644 index 0000000..2da274c --- /dev/null +++ b/clean_names_xml.py @@ -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 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".*?", wb, re.S) + if not m: + print("No block found - nothing to do.") + return + block = m.group(0) + entries = re.findall(r"]*>.*?", 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()] + "" + "".join(kept) + "" + 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)