Excel&VBA解説サイト「エクセルの神髄」様出題の問題集、
「VBA100本ノック」に対する私の回答と解説のページです。
100本ノックの出題リストはこちらから
excel-ubara.com
出題:自動席替え(行列と前後左右が全て違うように)
DB1.accdbから取引先&商品で集計出力する。
■出力項目
取引先CD,取引先名,商品CD,商品名,数量合計,金額合計,平均単価,標準単価,最低単価
平均単価は金額/数量(整数に丸め)
最低単価は全取引先での商品の最低単価
■抽出条件
平均単価 > 標準単価
※シートは任意

◇ 出題ページはこちら
ソースコード

Df_共通定義
Option Explicit ' グループ集計 ' (※1)(※2)に数式あり Public Const R1stグループ集計 = 2 Public Const C1stグループ集計 = 1 Public Enum CNoグループ集計 取引先CD = C1stグループ集計 取引先名 商品CD 商品名 数量合計 金額合計 平均単価 標準単価 最低単価 取引先_商品 削除判定 End Enum Public Const CLastグループ集計 = CNoグループ集計.削除判定 ' T売上 Public Const R1stT売上 = 2 Public Const C1stT売上 = 1 Public Enum CNoT売上 ID = C1stT売上 取引先CD 商品CD 日付 単価 数量 End Enum Public Const CLastT売上 = CNoT売上.数量 ' M取引先 Public Const R1stM取引先 = 2 Public Const C1stM取引先 = 1 Public Enum CNoM取引先 取引先CD = C1stM取引先 取引先名 End Enum Public Const CLastM取引先 = CNoM取引先.取引先名 ' M商品 Public Const R1stM商品 = 2 Public Const C1stM商品 = 1 Public Enum CNoM商品 商品CD = C1stM商品 商品名 標準単価 End Enum Public Const CLastM商品 = CNoM商品.標準単価
Op_実行モジュール
Option Explicit ' 実行プロシージャ Sub ★各Accessテーブルを最新に更新してグループ集計を実行する() Call エクセルの自動更新を停止する(False) Call 各テーブルをAccessファイルから読み込む Call グループ集計シートを更新する Call エクセルの自動更新を開始する MsgBox "Accessデータの読込とグループ集計を完了しました。" End Sub
Pr1_Accessデータの取込
Option Explicit Sub 各テーブルをAccessファイルから読み込む() ' 各シートを初期化 Call 指定行より下をすべて削除する(WS_T売上, R1stT売上) Call 指定行より下をすべて削除する(WS_M取引先, R1stM取引先) Call 指定行より下をすべて削除する(WS_M商品, R1stM商品) ' Accessファイルから各テーブルを読込 Dim Accessファイルパス As String Accessファイルパス = ThisWorkbook.Path & "\DB1.accdb" Call Accessの指定テーブルをセルに出力する(Accessファイルパス, "T売上", WS_T売上.Cells(R1stT売上, 1), False) Call Accessの指定テーブルをセルに出力する(Accessファイルパス, "M取引先", WS_M取引先.Cells(R1stM取引先, 1), False) Call Accessの指定テーブルをセルに出力する(Accessファイルパス, "M商品", WS_M商品.Cells(R1stM商品, 1), False) End Sub
Pr2_グループ集計を実行
Option Explicit ' グループ集計の実行 Sub グループ集計シートを更新する() ' 集計Key「取引先×商品」の取得と数量合計・金額合計の計算 Call 取引先_商品ごとの数量金額合計を計算して集計シートに出力する ' 最低単価の計算 Call 商品ごとの最低単価を計算して集計シートに反映する ' 各種数式計算 Call 数式計算列を更新する ' データを必要行だけに抽出 Call 平均単価が標準単価を下回るデータを削除する End Sub ' 集計Key「取引先×商品」の取得と数量合計・金額合計の計算 Sub 取引先_商品ごとの数量金額合計を計算して集計シートに出力する() ' 集計シートの初期化 Call 指定行より下をすべて削除する(WSグループ集計, R1stグループ集計) ' 各列のDictionaryを定義 Dim Dic数量合計 As New Dictionary Dim Dic金額合計 As New Dictionary ' 売上テーブルの全データをループ With WS_T売上 Dim R As Long For R = R1stT売上 To Get最終行(WS_T売上) ' Keyは取引先CD×商品CD Dim key取引先_商品: key取引先_商品 _ = .Cells(R, CNoT売上.取引先CD) & "_" & .Cells(R, CNoT売上.商品CD) Dic数量合計(key取引先_商品) = Dic数量合計(key取引先_商品) + .Cells(R, CNoT売上.数量) Dic金額合計(key取引先_商品) = Dic金額合計(key取引先_商品) _ + .Cells(R, CNoT売上.数量) * .Cells(R, CNoT売上.単価) Next End With ' 売上テーブルの全データをループ ' DictionaryのKey/Itemをセルに出力 With WSグループ集計 Call 配列をセルに出力する(.Cells(R1stグループ集計, CNoグループ集計.取引先_商品), Dic数量合計.Keys) Call 配列をセルに出力する(.Cells(R1stグループ集計, CNoグループ集計.数量合計), Dic数量合計.Items) Call 配列をセルに出力する(.Cells(R1stグループ集計, CNoグループ集計.金額合計), Dic金額合計.Items) ' Keyを分割 For R = R1stグループ集計 To Get最終行(WSグループ集計) .Cells(R, CNoグループ集計.取引先CD) = Left文字列まで(.Cells(R, CNoグループ集計.取引先_商品), "_") .Cells(R, CNoグループ集計.商品CD) = Right文字列まで(.Cells(R, CNoグループ集計.取引先_商品), "_") Next End With End Sub ' 最低単価の計算 Sub 商品ごとの最低単価を計算して集計シートに反映する() ' 商品ごとの最低単価をDictionaryで取得 Dim Dic最低単価 As Dictionary Set Dic最低単価 = GetDic商品ごとの最低単価 ' 全行へ反映 With WSグループ集計 Dim R As Long For R = R1stグループ集計 To Get最終行(WSグループ集計) .Cells(R, CNoグループ集計.最低単価) = Dic最低単価(.Cells(R, CNoグループ集計.商品CD).Value) Next End With End Sub Function GetDic商品ごとの最低単価() As Dictionary Dim Dic最低単価 As New Dictionary ' 売上テーブルの全データをループ With WS_T売上 Dim R As Long For R = R1stT売上 To Get最終行(WS_T売上) Dim key商品CD: key商品CD = .Cells(R, CNoT売上.商品CD).Value Dim item単価: item単価 = .Cells(R, CNoT売上.単価).Value ' 新出keyの登録 If Dic最低単価.Exists(key商品CD) = False Then Dic最低単価.Add key商品CD, item単価 ' 既出keyでより安価のitemがあれば更新 Else If Dic最低単価(key商品CD) > item単価 Then Dic最低単価(key商品CD) = item単価 End If End If Next End With ' 売上テーブルの全データをループ Set GetDic商品ごとの最低単価 = Dic最低単価 End Function ' 数式計算(※1) Sub 数式計算列を更新する() Call シート数式で計算する(GetRange指定列のデータ部分(WSグループ集計, CNoグループ集計.取引先名) _ , "=VLOOKUP(A2,M取引先!A:B,2,FALSE)") Call シート数式で計算する(GetRange指定列のデータ部分(WSグループ集計, CNoグループ集計.商品名) _ , "=VLOOKUP($C2,M商品!$A:$C,2,FALSE)") Call シート数式で計算する(GetRange指定列のデータ部分(WSグループ集計, CNoグループ集計.標準単価) _ , "=VLOOKUP($C2,M商品!$A:$C,3,FALSE)") Call シート数式で計算する(GetRange指定列のデータ部分(WSグループ集計, CNoグループ集計.平均単価) _ , "=IF(E2<>0,ROUND(F2/E2,0),0)") End Sub ' データの抽出(不要行の削除)(※2) Sub 平均単価が標準単価を下回るデータを削除する() Call シート数式で計算する(GetRange指定列のデータ部分(WSグループ集計, CNoグループ集計.削除判定) _ , "=G2<=H2") Call 指令列がTRUEの行を高速削除する(GetRangeフィルターデータ部分(WSグループ集計), CNoグループ集計.削除判定) End Sub
Ut_汎用関数
Option Explicit ' Accessテーブルの取込 Sub Accessの指定テーブルをセルに出力する(Accessファイルパス As String, テーブル名 As String _ , 出力起点セル As Range, is見出しも出力する As Boolean) Dim 接続 As Object: Set 接続 = CreateObject("ADODB.Connection") 接続.Open "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" & Accessファイルパス & ";" Dim レコードセット As Object: Set レコードセット = CreateObject("ADODB.Recordset") レコードセット.Open "SELECT * FROM " & テーブル名, 接続 ' フィールド名を第1行に出力 If is見出しも出力する Then Dim C As Long For C = 1 To レコードセット.Fields.Count 出力起点セル.Cells(1, C).Value = レコードセット.Fields(C - 1).Name Next End If ' 第2行以降に全レコードを出力 出力起点セル.Cells(IIf(is見出しも出力する, 2, 1), 1).CopyFromRecordset レコードセット レコードセット.Close: Set レコードセット = Nothing 接続.Close: Set 接続 = Nothing End Sub ' 指定行より下の削除 Sub 指定行より下をすべて削除する(指定シート As Worksheet, 指定行 As Long) With 指定シート 指定シート.Rows(指定行).Resize(.UsedRange.Rows.Count).Delete End With End Sub ' 最終行の取得 Function Get最終行(指定オブジェクト As Variant, Optional ByVal C As Long = -1) As Long ' 渡されたオブジェクトからセル範囲を取得 Dim 対象セル範囲 As Range Select Case TypeName(指定オブジェクト) Case "Range" If 指定オブジェクト.Cells.CountLarge = 1 Then ' 単独セルにはCurrentRegionを取る Set 対象セル範囲 = 指定オブジェクト.CurrentRegion Else Set 対象セル範囲 = 指定オブジェクト End If Case "Worksheet" Set 対象セル範囲 = 指定オブジェクト.UsedRange Case "AutoFilter", "ListObject" Set 対象セル範囲 = 指定オブジェクト.Range Case Else Err.Raise 1000, , "対象外のオブジェクト「" & TypeName(指定オブジェクト) & "」が指定されました。" End Select ' エリアの最終行を取得 Get最終行 = 対象セル範囲.Rows.Count + 対象セル範囲.Row - 1 ' 列が指定されていればその列の入力最終行を取得 If C <> -1 Then Do While 対象セル範囲.Worksheet.Cells(Get最終行, C) = "" Get最終行 = Get最終行 - 1 If Get最終行 < 対象セル範囲.Row Then Get最終行 = 0 Exit Function End If Loop End If End Function ' 最終列の取得 Function Get最終列(指定オブジェクト As Variant, Optional ByVal R As Long = -1) As Long ' 渡されたオブジェクトからセル範囲を取得 Dim 対象セル範囲 As Range Select Case TypeName(指定オブジェクト) Case "Range" If 指定オブジェクト.Cells.CountLarge = 1 Then ' 単独セルにはCurrentRegionを取る Set 対象セル範囲 = 指定オブジェクト.CurrentRegion Else Set 対象セル範囲 = 指定オブジェクト End If Case "Worksheet" Set 対象セル範囲 = 指定オブジェクト.UsedRange Case "AutoFilter", "ListObject" Set 対象セル範囲 = 指定オブジェクト.Range Case Else Err.Raise 1000, , "対象外のオブジェクト「" & TypeName(指定オブジェクト) & "」が指定されました。" End Select ' エリアの最終列を取得 Get最終列 = 対象セル範囲.Columns.Count + 対象セル範囲.Column - 1 ' 列が指定されていればその列の入力最終行を取得 If R <> -1 Then Do While 対象セル範囲.Worksheet.Cells(R, Get最終列) = "" Get最終列 = Get最終列 - 1 If Get最終列 < 対象セル範囲.Column Then Get最終列 = 0 Exit Function End If Loop End If End Function ' 行番号の検索 Function Match行番号(ByVal 検索値 As Variant, 検索エリア As Range) As Long On Error Resume Next If IsDate(検索値) Then Dim x: x = CDbl(検索値) If Err.Number = 0 Then Match行番号 = Fx.Match(CDbl(検索値), 検索エリア, 0) + 検索エリア.Row - 1 Exit Function End If End If Match行番号 = Fx.Match(検索値, 検索エリア, 0) + 検索エリア.Row - 1 End Function ' 列番号の検索 Function Match列番号(ByVal 検索値 As Variant, 検索エリア As Range) As Long On Error Resume Next If IsDate(検索値) Then Match列番号 = Fx.Match(CDbl(検索値), 検索エリア, 0) + 検索エリア.Column - 1 Else Match列番号 = Fx.Match(検索値, 検索エリア, 0) + 検索エリア.Column - 1 End If End Function ' データ件数 Function Countデータ件数(対象シート As Worksheet) As Long If 対象シート.AutoFilterMode = False Then Exit Function With 対象シート.AutoFilter.Range Countデータ件数 = .Rows.Count + .Row - 2 End With End Function ' 各列のデータ部分 Function GetRange指定列のデータ部分(対象シート As Worksheet, 対象列 As Long) As Range If Countデータ件数(対象シート) = 0 Then Exit Function Set GetRange指定列のデータ部分 = 対象シート.Cells(対象シート.AutoFilter.Range.Row + 1, 対象列) _ .Resize(Countデータ件数(対象シート)) End Function ' フィルターデータ部 Function GetRangeフィルターデータ部分(対象シート As Worksheet) As Range Set GetRangeフィルターデータ部分 = Resizeエリアの拡大縮小(対象シート.AutoFilter.Range, -1) End Function ' エリアの拡大縮小 Function Resizeエリアの拡大縮小(指定エリア As Range, Optional R上方向 As Long = 0, Optional R下方向 As Long = 0 _ , Optional C左方向 As Long = 0, Optional C右方向 As Long = 0) As Range On Error Resume Next With 指定エリア Set Resizeエリアの拡大縮小 = .Resize(.Rows.Count + R上方向 + R下方向, .Columns.Count + C左方向 + C右方向) End With Set Resizeエリアの拡大縮小 = Resizeエリアの拡大縮小.Offset(-R上方向, -C左方向) End Function ' 不要行の削除 Sub 指令列がTRUEの行を高速削除する(データエリア全体 As Range, 判定列 As Long) Dim ws対象シート As Worksheet: Set ws対象シート = データエリア全体.Worksheet Dim column判定列 As Range Set column判定列 = Intersect(データエリア全体, ws対象シート.Columns(判定列)) If column判定列 Is Nothing Then Exit Sub Dim RCount削除行数 As Long RCount削除行数 = Fx.CountIf(column判定列, True) If RCount削除行数 = 0 Then Exit Sub ' ソートしてTRUEを1エリアにまとめる データエリア全体.Sort ws対象シート.Cells(1, 判定列) ' 第1TRUE行から削除行数分を削除 Dim R1st削除開始行 As Long R1st削除開始行 = Match行番号(True, column判定列) ws対象シート.Rows(R1st削除開始行).Resize(RCount削除行数).Delete End Sub ' シート数式計算 Sub シート数式で計算する(計算エリア As Range, シート数式 As String) 計算エリア.Formula = シート数式 計算エリア.Value = 計算エリア.Value End Sub ' 文字列検索切り出しLeft/Right/Mid Function Left文字列まで(ByVal 元テキスト As String, ByVal 検索値 As String _ , Optional is検索値を結果に含む As Boolean = False) As String Dim instr位置 As Long: instr位置 = InStr(元テキスト, 検索値) If instr位置 > 0 Then Left文字列まで = Left(元テキスト, instr位置 - 1) & IIf(is検索値を結果に含む, 検索値, "") End If End Function Function Right文字列まで(ByVal 元テキスト As String, ByVal 検索値 As String _ , Optional is検索値を結果に含む As Boolean = False) As String Dim instrRev位置 As Long: instrRev位置 = InStrRev(元テキスト, 検索値) If instrRev位置 > 0 Then Right文字列まで = IIf(is検索値を結果に含む, 検索値, "") _ & Mid(元テキスト, instrRev位置 + Len(検索値)) End If End Function Function Mid文字列から文字列(ByVal 元テキスト As String, ByVal 前検索値 As String, ByVal 後検索値 As String _ , Optional is前検索値を結果に含む As Boolean = False, Optional is後検索値を結果に含む As Boolean = False) As String Dim instr前値 As Long: instr前値 = InStr(元テキスト, 前検索値) If instr前値 = 0 Then Exit Function Dim instr後値 As Long: instr後値 = InStr(instr前値 + Len(前検索値) + 1, 元テキスト, 後検索値) If instr後値 = 0 Then Exit Function Mid文字列から文字列 = _ IIf(is前検索値を結果に含む, 前検索値, "") & _ Mid(元テキスト, instr前値 + Len(前検索値), instr後値 - instr前値 - Len(前検索値)) & _ IIf(is後検索値を結果に含む, 後検索値, "") End Function ' Dictionaryを新規シートに出力する Sub Dictionaryを新規シートに出力する(Dic As Dictionary) With Workbooks.Add.Worksheets(1) Call 配列をセルに出力する(.Range("A1"), Dic.Keys) Call 配列をセルに出力する(.Range("B1"), Dic.Items) End With End Sub ' 1次元配列 → セル Sub 配列をセルに出力する(出力始点セル As Range, Arr出力配列 As Variant _ , Optional is行方向へ出力 As Boolean = True) If UBound(Arr出力配列) = -1 Then Exit Sub ' ◇ 要素数が1ならば単セルに出力 If Count配列の要素数(Arr出力配列) = 1 Then 出力始点セル.Value = Arr出力配列(0) ' Transposeで方向指定して出力 ElseIf is行方向へ出力 Then 出力始点セル.Resize(Count配列の要素数(Arr出力配列), 1) = Fx.Transpose(Arr出力配列) Else 出力始点セル.Resize(1, Count配列の要素数(Arr出力配列)) = Fx.Transpose(Fx.Transpose(Arr出力配列)) End If End Sub ' 配列を新規シートに出力する Sub 配列を新規シートに出力する(Arr) Workbooks.Add.Worksheets(1).Range("A1") _ .Resize(Count配列の要素数(Arr, 1), Count配列の要素数(Arr, 2)) = Arr End Sub ' 配列の要素数の取得 Function Count配列の要素数(Arr, Optional 次元 = 1) As Long Count配列の要素数 = UBound(Arr, 次元) - LBound(Arr, 次元) + 1 End Function ' 配列の次元数の取得 ' 参考:https://www.limecode.jp/entry/utility/get-array-dimension Function Get配列の次元数(Arr As Variant) As Long ' 渡された変数が配列ではない場合は0を返すこととする If IsArray(Arr) = False Then Get配列の次元数 = 0: Exit Function ' エラーが出るまでUBoundを取得してみる Dim tmp Dim 次元数 As Long: 次元数 = 0 On Error Resume Next Do While Err.Number = 0 次元数 = 次元数 + 1 tmp = UBound(Arr, 次元数) Loop On Error GoTo 0 ' エラーが出たひとつ前の次元が求める次元数 Get配列の次元数 = 次元数 - 1 End Function ' セルのリストを1次元配列に ' 参考:https://www.limecode.jp/entry/utility/getarray-from-rangeobject Function GetArrayセルのリストを1次元配列に変換する(セルリスト As Range) As Variant ' ◇ 単セル → 値そのまま If セルリスト.Cells.Count = 1 Then GetArrayセルのリストを1次元配列に変換する = Array(セルリスト.Value) Exit Function End If ' 1列データの時はTransposeで高速化 If セルリスト.Areas.Count = 1 And セルリスト.Columns.Count = 1 And セルリスト.Count <= 65535 Then GetArrayセルのリストを1次元配列に変換する = Fx.Transpose(セルリスト) Exit Function End If ' それ以外は1セルずつ値を格納 Dim Arr生成配列() As Variant ReDim Arr生成配列(1 To セルリスト.Cells.Count) As Variant Dim i As Long: i = 1 Dim Cell As Range For Each Cell In セルリスト.Cells Arr生成配列(i) = Cell.Value i = i + 1 Next GetArrayセルのリストを1次元配列に変換する = Arr生成配列 End Function ' WorksheetFunctionの短縮取得 ' 参考:https://www.limecode.jp/entry/utility/shortcall-worksheetfunction Function Fx() As WorksheetFunction Set Fx = WorksheetFunction End Function ' QueryTableによるWEB読み込みの汎用関数 ' 参考:https://www.limecode.jp/entry/utility/querytables-import-web-table Sub QueryTableでWEB上の表を読み込む(出力起点セル As Range _ , 対象URL As String, Optional テーブルNo As Long = -1 _ , Optional is書式リンク含む As Boolean = False, Optional is日付に変換する As Boolean = False) With 出力起点セル.Worksheet.QueryTables.Add("URL;" & 対象URL, 出力起点セル) If テーブルNo = -1 Then ' テーブルNo省略時は全テーブルを対象 .WebSelectionType = xlAllTables Else .WebTables = xlSpecifiedTables .WebTables = テーブルNo End If .WebFormatting = IIf(is書式リンク含む, xlWebFormattingAll, xlWebFormattingNone) .WebDisableDateRecognition = Not (is日付に変換する) .Refresh BackgroundQuery:=False .Delete End With End Sub ' 自動更新の停止 Sub エクセルの自動更新を停止する(isブック計算をOFFに As Boolean _ , Optional is画面更新をOFFに As Boolean = True _ , Optional isイベントをOFFに As Boolean = True) If isブック計算をOFFに Then Application.Calculation = xlCalculationManual If is画面更新をOFFに Then Application.ScreenUpdating = False If isイベントをOFFに Then Application.EnableEvents = False End Sub ' 自動更新の開始 Sub エクセルの自動更新を開始する() With Application .Calculation = xlCalculationAutomatic .ScreenUpdating = True .EnableEvents = True .StatusBar = False .DisplayAlerts = True End With End Sub
解説
本問題はSQLでAccessからデータをとってくる問題で、
- SQLでマスタの結合や抽出まですべてやってしまう。
- SQLでは全データをとってくるだけにして、後の処理はVBAと数式でやる。
の2通りのやり方が考えられますが、私の解答では後者を用いました。
グループ集計にはDictionaryを用いており、
ExcelVBA上ではSQLよりもかなり高速で集計を実行することができます。
また「Access指定テーブルをシートに書き出し」を汎用関数化しているので、
一度作れば中身を見なくてもよいという恩恵も得ることができます。
この関数さえ用意しておけばSQLのことは一旦忘れていいということですね。
さてメインロジックの解説ですが、今回は集計のキーが二つ存在し、
- 集計データは「取引先×商品」ごとにグループ化
- 最低単価は「商品」ごとに取得
この二つのキーでデータを走査する必要があります。
同時にやるのは可読性を損ないますので、
それぞれ単独のDictionary&Functionで処理しました。
取引先×商品のような複数の要素をキーとして使用する場合は、
その二つを文字列結合してDicitonaryのKeyに使うのが簡単です。
キーの存在判定はExists一発で終わりますし、
なんなら本コードのようにただの代入文オンリーでも処理が書けます。
※ Dictionaryは「Keyがなければその場で作る」という仕様があります。
SUMIFS、COUNTIFS系の処理を簡単に書けるうえ、
処理速度も数百倍になったりしますので是非活用してください。
本解答は配列・Dictionary・Formulaというマクロ高速化トリオをすべて使った、
高速マクロの教科書コードになっています。
データ処理を高速化したい場合は本コードを参考にしてみてください。