Historia z biura: jak jedno makro uratowało mój poranek (VBA story)

Każdy, kto pracuje w finansach, zna ten moment: trzeba przygotować raport konsolidacyjny.

Dane spływają z różnych oddziałów, tysiące wierszy w arkuszu „dane”, a w „zestawieniu kosztów” ma się pojawić podsumowanie. Tyle że zawsze coś się rozjedzie w jednym miejscu brakuje liczby, w drugim ktoś źle przepisał koszt. Ręczne sprawdzanie tego wszystkiego zajmowało godziny.

Dlatego powstało makro VBA, które robi tę pracę automatycznie.

Makro zaczyna od odszukania właściwych arkuszy. Nie musimy podawać ich nazw na sztywno zatem wystarczy, że w nazwie zakładki jest słowo „dane” albo „zestawienie kosztów”.

Option Explicit ’ Wymusza deklarowanie zmiennych (chroni przed literówkami)

’========================
’ Funkcje pomocnicze
’========================

Private Function ColByName(sh As Worksheet, nm As String) As Long
’ Szuka w wierszu 1 nagłówka zawierającego fragment nm
’ Zwraca numer kolumny, 0 jeżeli nie znajdzie
Dim c As Range
Set c = sh.Rows(1).Find(What:=nm, LookIn:=xlValues, LookAt:=xlPart, MatchCase:=False)
If Not c Is Nothing Then ColByName = c.Column
End Function

Private Function Nz(v, Optional d As Double = 0) As Double
’ Bezpieczna konwersja: błędy/puste/tekst -> d (domyślnie 0), liczby -> CDbl
If IsError(v) Or IsEmpty(v) Or v = „” Then
Nz = d
ElseIf IsNumeric(v) Then
Nz = CDbl(v)
Else
Nz = d
End If
End Function

’========================
’ RAPORT: budowa zestawienia od zera
’========================
Public Sub Raport_OK()
Dim shD As Worksheet, shZ As Worksheet, ws As Worksheet

' Automatyczne wyszukanie arkuszy po fragmencie nazwy
For Each ws In ThisWorkbook.Worksheets
    If InStr(1, LCase(ws.Name), "dane") > 0 Then Set shD = ws
    If InStr(1, LCase(ws.Name), "zestawienie") > 0 And InStr(1, LCase(ws.Name), "koszt") > 0 Then Set shZ = ws
Next ws

' Jeżeli nie ma któregoś z arkuszy – zakończ
If shD Is Nothing Or shZ Is Nothing Then Exit Sub

' Ustalenie numerów kolumn po nagłówkach
Dim cKGW As Long, cData As Long, cP As Long, cK As Long, cR As Long, cKon As Long
cKGW = ColByName(shD, "Numer KGW")
cData = ColByName(shD, "Data")
cP    = ColByName(shD, "Początkowa wartość")
cK    = ColByName(shD, "Koszt")
cR    = ColByName(shD, "Rozchod")
cKon  = ColByName(shD, "Końcowa wartość")

' Ostatni wiersz na podstawie kolumny KGW
Dim lastRow As Long
lastRow = shD.Cells(shD.Rows.Count, cKGW).End(xlUp).Row

' Struktury agregujące
Dim dict1 As Object, uniq56 As Object
Set dict1 = CreateObject("Scripting.Dictionary") ' klucz: 8-znakowy prefiks KGW; val: [minData,sumP,sumK,sumR,sumKon]
Set uniq56 = CreateObject("Scripting.Dictionary") ' zbiór unikalnych KGW zakończonych na "56"

Dim M(1 To 5, 1 To 9) As Double ' macierz: rodzaje 52..56 → 1..5; województwa 1..9

' Przejście po wierszach danych
Dim r As Long
For r = 2 To lastRow
    Dim nf As String
    nf = Trim$(CStr(shD.Cells(r, cKGW).Value)) ' pełny numer KGW w wierszu
    If nf <> "" Then
        Dim s8 As String
        s8 = Left$(nf, 8) ' prefiks 8-znakowy – klucz grupy

        ' Inicjalizacja grupy przy pierwszym trafieniu
        If Not dict1.Exists(s8) Then dict1.Add s8, Array(shD.Cells(r, cData).Value, 0#, 0#, 0#, 0#)

        Dim v As Variant
        v = dict1(s8) ' v(0)=minData; v(1)=sumP; v(2)=sumK; v(3)=sumR; v(4)=sumKon

        ' Najstarsza (minimalna) data w grupie
        If v(0) = 0 Or v(0) > shD.Cells(r, cData).Value Then v(0) = shD.Cells(r, cData).Value

        ' Sumowanie wartości z wiersza (zabezpieczenie Nz)
        v(1) = v(1) + Nz(shD.Cells(r, cP).Value)   ' początkowa
        v(2) = v(2) + Nz(shD.Cells(r, cK).Value)   ' koszt
        v(3) = v(3) + Nz(shD.Cells(r, cR).Value)   ' rozchód
        v(4) = v(4) + Nz(shD.Cells(r, cKon).Value) ' końcowa
        dict1(s8) = v

        ' Dekodowanie województwa i rodzaju z numeru KGW
        Dim woj As Long, rodz As Long, idx As Long
        woj = Val(Left$(nf, 1))        ' pierwsza cyfra → województwo (1..9)
        rodz = Val(Right$(nf, 2))      ' dwie ostatnie → rodzaj (52..56)
        idx = rodz - 51                ' 52→1 ... 56→5

        ' Sumowanie kosztów do macierzy M(rodzaj, województwo)
        If idx >= 1 And idx <= 5 And woj >= 1 And woj <= 9 Then
            M(idx, woj) = M(idx, woj) + Nz(shD.Cells(r, cK).Value)
        End If

        ' Zbieranie unikalnych KGW kończących się na "56"
        If Right$(nf, 2) = "56" Then uniq56(nf) = 1
    End If
Next r

' Sort kluczy (prefiksy s8 rosnąco)
Dim keys() As Variant, i As Long, j As Long, tmp
keys = dict1.Keys
If UBound(keys) >= 0 Then
    For i = LBound(keys) To UBound(keys) - 1
        For j = i + 1 To UBound(keys)
            If keys(j) < keys(i) Then
                tmp = keys(i): keys(i) = keys(j): keys(j) = tmp
            End If
        Next j
    Next i
End If

' Czyszczenie obszaru wyników w zestawieniu
shZ.Range("A3:F100000").ClearContents

' Zapis wyników grupy s8 do zestawienia
Dim rowOut As Long
rowOut = 3
For i = LBound(keys) To UBound(keys)
    Dim vv As Variant
    vv = dict1(keys(i))
    shZ.Cells(rowOut, 1).Value = keys(i)           ' klucz s8
    shZ.Cells(rowOut, 2).Value = vv(0)             ' najstarsza data
    shZ.Cells(rowOut, 3).Value = Round(vv(1), 2)   ' suma P
    shZ.Cells(rowOut, 4).Value = Round(vv(2), 2)   ' suma K
    shZ.Cells(rowOut, 5).Value = Round(vv(3), 2)   ' suma R
    shZ.Cells(rowOut, 6).Value = Round(vv(4), 2)   ' suma Kon
    rowOut = rowOut + 1
Next i

' Wstawianie macierzy M do tabeli 52..56 × województwa
Dim r52 As Range, hdrRow As Long, baseRow As Long, sumRow As Long
Set r52 = shZ.Columns(8).Find(What:=52, LookIn:=xlValues, LookAt:=xlWhole, MatchCase:=False) ' szukamy w kol. H liczby 52
If r52 Is Nothing Then Exit Sub
baseRow = r52.Row          ' pierwszy wiersz z "52"
hdrRow = baseRow - 1       ' wiersz nagłówków z numerami województw
sumRow = baseRow + 5       ' wiersz sum (po 5 wierszach: 52..56)

' Zmapowanie kolumn nagłówków 1..9 do ich indeksów w arkuszu
Dim wojCol(1 To 9) As Long
For j = 1 To 9
    Dim c As Range
    Set c = shZ.Rows(hdrRow).Find(What:=j, LookIn:=xlValues, LookAt:=xlWhole, MatchCase:=False)
    If Not c Is Nothing Then wojCol(j) = c.Column
Next j

' Czyszczenie bloku wyników województw
shZ.Range(shZ.Cells(baseRow, 9), shZ.Cells(sumRow, 17)).ClearContents

' Wpisanie M(rodzaj, woj) do odpowiednich komórek
For i = 1 To 5          ' 52..56
    For j = 1 To 9      ' woj 1..9
        If wojCol(j) > 0 Then
            shZ.Cells(baseRow + i - 1, wojCol(j)).Value = Round(M(i, j), 2)
        End If
    Next j
Next i

' Sumy kolumn województw (wiersz sumRow)
For j = 1 To 9
    Dim s As Double
    s = 0
    For i = 1 To 5
        s = s + M(i, j)
    Next i
    If wojCol(j) > 0 Then shZ.Cells(sumRow, wojCol(j)).Value = Round(s, 2)
Next j

' Licznik unikalnych KGW kończących na "56" w L16
shZ.Range("L16").Value = uniq56.Count

End Sub

’========================
’ WALIDACJA: porównanie zestawienia ze źródłem
’========================
Public Sub Walidacja_Prosta()
Dim shD As Worksheet, shZ As Worksheet, ws As Worksheet

' Ponowne znalezienie arkuszy
For Each ws In ThisWorkbook.Worksheets
    If InStr(1, LCase(ws.Name), "dane") > 0 Then Set shD = ws
    If InStr(1, LCase(ws.Name), "zestawienie") > 0 And InStr(1, LCase(ws.Name), "koszt") > 0 Then Set shZ = ws
Next ws
If shD Is Nothing Or shZ Is Nothing Then Exit Sub

' Alternatywny sposób na kolumny: Find bezpośrednio (równoważne ColByName)
Dim cKGW As Long, cData As Long, cP As Long, cK As Long, cR As Long, cKon As Long
cKGW = shD.Rows(1).Find("Numer KGW", , xlValues, xlPart).Column
cData = shD.Rows(1).Find("Data", , xlValues, xlPart).Column
cP    = shD.Rows(1).Find("Początkowa wartość", , xlValues, xlPart).Column
cK    = shD.Rows(1).Find("Koszt", , xlValues, xlPart).Column
cR    = shD.Rows(1).Find("Rozchod", , xlValues, xlPart).Column
cKon  = shD.Rows(1).Find("Końcowa wartość", , xlValues, xlPart).Column

' Ostatni wiersz
Dim lastRow As Long
lastRow = shD.Cells(shD.Rows.Count, cKGW).End(xlUp).Row

' Słowniki do walidacji (oddzielne od tych w Raport_OK)
Dim dict As Object, uniq56 As Object
Set dict = CreateObject("Scripting.Dictionary")
Set uniq56 = CreateObject("Scripting.Dictionary")

' Macierz M ponownie – policzymy ją niezależnie
Dim M(1 To 5, 1 To 9) As Double

' Zmienne na bieżąco w pętli
Dim r As Long, nf As String, s8 As String, v As Variant, woj As Long, rodz As Long, idx As Long

' Budowanie słownika i macierzy na podstawie źródła (jak w Raport_OK)
For r = 2 To lastRow
    nf = Trim$(CStr(shD.Cells(r, cKGW).Value))
    If nf <> "" Then
        s8 = Left$(nf, 8)
        If Not dict.Exists(s8) Then dict.Add s8, Array(shD.Cells(r, cData).Value, 0#, 0#, 0#, 0#)
        v = dict(s8)

        ' Min data
        If v(0) = 0 Or v(0) > shD.Cells(r, cData).Value Then v(0) = shD.Cells(r, cData).Value

        ' Sumy (tu jawna konwersja z zamianą przecinków na kropki)
        v(1) = v(1) + CDbl(Val(Replace(CStr(shD.Cells(r, cP).Value),  ",", ".")))
        v(2) = v(2) + CDbl(Val(Replace(CStr(shD.Cells(r, cK).Value),  ",", ".")))
        v(3) = v(3) + CDbl(Val(Replace(CStr(shD.Cells(r, cR).Value),  ",", ".")))
        v(4) = v(4) + CDbl(Val(Replace(CStr(shD.Cells(r, cKon).Value),",", ".")))
        dict(s8) = v

        ' Macierz M (koszty wg rodzaj×woj)
        woj = Val(Left$(nf, 1))
        rodz = Val(Right$(nf, 2))
        idx = rodz - 51
        If idx >= 1 And idx <= 5 And woj >= 1 And woj <= 9 Then
            M(idx, woj) = M(idx, woj) + CDbl(Val(Replace(CStr(shD.Cells(r, cK).Value), ",", ".")))
        End If

        ' Zbiór unikalnych z końcówką "56"
        If Right$(nf, 2) = "56" Then uniq56(nf) = 1
    End If
Next r

' Licznik błędów i tolerancja
Dim errs As Long, tol As Double
tol = 0.01

' Porównanie sekcji A: wiersze 3.. do pierwszej pustej w kolumnie A
Dim rowT1 As Long
rowT1 = 3
Do While CStr(shZ.Cells(rowT1, 1).Value) <> ""
    s8 = CStr(shZ.Cells(rowT1, 1).Value) ' klucz z zestawienia
    If dict.Exists(s8) Then
        v = dict(s8)
        ' Porównania kolumn 3..6 (sumy) z zaokrągleniem do 2 miejsc i tolerancją
        If Abs(shZ.Cells(rowT1, 3).Value - Round(v(1), 2)) > tol Then shZ.Cells(rowT1, 3).Interior.Color = vbYellow: errs = errs + 1
        If Abs(shZ.Cells(rowT1, 4).Value - Round(v(2), 2)) > tol Then shZ.Cells(rowT1, 4).Interior.Color = vbYellow: errs = errs + 1
        If Abs(shZ.Cells(rowT1, 5).Value - Round(v(3), 2)) > tol Then shZ.Cells(rowT1, 5).Interior.Color = vbYellow: errs = errs + 1
        If Abs(shZ.Cells(rowT1, 6).Value - Round(v(4), 2)) > tol Then shZ.Cells(rowT1, 6).Interior.Color = vbYellow: errs = errs + 1
    Else
        ' Brak klucza w źródle – cały blok 3..6 na czerwono
        shZ.Range(shZ.Cells(rowT1, 3), shZ.Cells(rowT1, 6)).Interior.Color = vbRed: errs = errs + 4
    End If
    rowT1 = rowT1 + 1
Loop

' Porównanie sekcji B: tabela 52..56 × woj (w kolumnie 8 szukamy "52")
Dim r52 As Range
Set r52 = shZ.Columns(8).Find(What:=52, LookIn:=xlValues, LookAt:=xlWhole, MatchCase:=False)
If Not r52 Is Nothing Then
    Dim baseRow As Long: baseRow = r52.Row
    Dim hdrRow As Long:  hdrRow  = baseRow - 1
    Dim sumRow As Long:  sumRow  = baseRow + 5

    ' Kolumny województw 1..9
    Dim wojCol(1 To 9) As Long, j As Long, i As Long
    For j = 1 To 9
        Dim c As Range
        Set c = shZ.Rows(hdrRow).Find(What:=j, LookIn:=xlValues, LookAt:=xlWhole, MatchCase:=False)
        If Not c Is Nothing Then wojCol(j) = c.Column
    Next j

    ' Sprawdzenie poszczególnych komórek M
    For i = 1 To 5
        For j = 1 To 9
            If wojCol(j) > 0 Then
                If Abs(shZ.Cells(baseRow + i - 1, wojCol(j)).Value - Round(M(i, j), 2)) > tol Then
                    shZ.Cells(baseRow + i - 1, wojCol(j)).Interior.Color = vbYellow: errs = errs + 1
                End If
            End If
        Next j
    Next i

    ' Sprawdzenie sum kolumn (wiersz sumRow)
    For j = 1 To 9
        Dim s As Double: s = 0
        For i = 1 To 5: s = s + M(i, j): Next i
        If wojCol(j) > 0 Then
            If Abs(shZ.Cells(sumRow, wojCol(j)).Value - Round(s, 2)) > tol Then
                shZ.Cells(sumRow, wojCol(j)).Interior.Color = vbYellow: errs = errs + 1
            End If
        End If
    Next j
End If

' Sprawdzenie L16 = liczba unikalnych KGW z końcówką "56"
If Abs(CDbl(Val(Replace(CStr(shZ.Range("L16").Value), ",", "."))) - uniq56.Count) > 0.5 Then
    shZ.Range("L16").Interior.Color = vbYellow: errs = errs + 1
End If

' Komunikat końcowy
If errs = 0 Then
    MsgBox "OK", vbInformation
Else
    MsgBox "Nieprawidłowości: " & errs, vbExclamation
End If

End Sub

Option Explicit – zmusza do deklarowania zmiennych. Bez tego „xrs” zamiast „xrs” przejdzie, ale policzy coś do śmieciowej zmiennej. Tu nie przejdzie.

ColByName – zdejmuje z barków pamiętanie numerów kolumn. Wystarczy podać fragment nagłówka, np. „Koszt”, a funkcja znajdzie właściwą kolumnę, nawet gdy ktoś zmieni kolejność.

Nz – bezpieczna konwersja do liczby. Jeśli w danych jest pusta komórka, tekst albo błąd, funkcja zwróci 0 (albo inną domyślną wartość). Dzięki temu sumy się nie wywalają.

Raport_OK – serce konsolidacji. Najpierw odnajduje arkusze „dane” i „zestawienie koszt…”. Potem ustala, gdzie są kolumny: „Numer KGW”, „Data”, „Początkowa wartość”, „Koszt”, „Rozchod”, „Końcowa wartość”. Dalej leci po wierszach danych. Z każdego numeru KGW bierze pierwsze 8 znaków jako klucz grupy, a z końcówki i początku numeru wyciąga rodzaj (52–56) i województwo (1–9). W słowniku dict1 trzyma pięć wartości dla każdej grupy: najstarszą datę oraz sumy P/K/R/Kon. W macierzy M zbiera koszty w układzie rodzaj×województwo. Na końcu sortuje klucze, czyści arkusz raportu, wpisuje wiersze wyników i wstawia tablicę 52..56 × województwa, plus sumy kolumn. Do komórki L16 wpisuje, ile unikalnych numerów kończy się na „56”.

Walidacja_Prosta – drugi biegun procesu. Niezależnie od Raport_OK jeszcze raz liczy te same sumy ze źródła do słownika i macierzy. Następnie porównuje to z tym, co jest w „zestawieniu”. Tolerancja to 0,01, więc drobne różnice wynikające z zaokrągleń nie wywołają fałszywego alarmu. Jeżeli znajdzie rozjazdy, podświetla komórki na żółto. Jeżeli w raporcie istnieje wiersz bez odpowiedniego klucza w danych, zaznacza cały blok 3..6 na czerwono. Sprawdza też tablicę rodzaj×województwo i sumy kolumn, a na końcu porównuje liczbę w L16 z policzonym zbiorem uniq56. Gdy wszystko gra wyświetla „OK”. Inaczej podaje liczbę nieprawidłowości.

Dlaczego to działa dobrze w codziennej pracy

Nie trzeba nic przepinać, gdy zmieni się układ kolumn czy nazwa arkusza – ważne, by zawierała słowo-klucz („dane”, „zestawienie”, „koszt”). Konsolidacja i walidacja są od siebie niezależne: możesz najpierw zbudować raport (Raport_OK), a potem go zweryfikować (Walidacja_Prosta). Kolory od razu prowadzą wzrok do problemów, a licznik błędów na końcu zamyka temat bez wertowania całego pliku.

Na co uważać i jak dopieścić

  1. Jeśli któryś nagłówek jest bardzo podobny do innego, LookAt:=xlPart może złapać zły. W razie potrzeby zmień na xlWhole.
  2. Gdyby w pliku były dodatkowe dane albo zakres danych – koniecznie poszerz wymiary macierzy M.
  3. Jeśli raport bywa bardzo duży, dodaj Application.ScreenUpdating = False i Application.Calculation = xlCalculationManual na start i przywracanie na końcu – przyspieszy to działanie.