C# Entity Framework Coreで日次売上と歩合(コミッション)を多段階条件で計算するLINQ実装ガイド

ASP.NET Core と Entity Framework Core で、日次売上とグループ別の歩合(コミッション)をきれいに集計したい…でも複雑な LINQ が SQL に翻訳できずにつまずくことは少なくありません。本記事では、SQL 変換エラーを回避しつつ「その日達成した最高ランクの歩合だけ」を合計する実践的なパターンを詳しく解説します。

目次

背景:日次売上 × 歩合(コミッション)計算の要件整理

まずは、今回の要件を整理します。対象は ASP.NET (C#) + Entity Framework Core 環境です。

  • 商品グループごとに「売上目標値 (Targetvalue)」と「歩合額 (discount)」が複数段階で設定されている
  • 売上は 日付単位で集計したい
  • 1 日の中で、各グループごとに達成したランクのうち、最高ランクの歩合だけを採用する
  • 最終的に欲しいのは「日付ごとの売上合計」と「日付ごとの歩合合計」の日次レポート

イメージとしては、次のような結果を LINQ だけで取りたいケースです。

reportid日付売上合計歩合合計
12025-02-011,20020
22025-02-023,000300

ところが、この要件をそのまま 1 本の LINQ クエリで書いて DB に投げようとすると、次のような問題が起こりがちです。

  • GroupBy の中でさらにサブクエリを実行した結果、EF Core が SQL に翻訳できない
  • FirstOrDefault() が null を返したあとに .discount を参照して、NullReferenceException
  • トランザクションや DB 負荷を気にせず欲張って 1 クエリで済ませようとして、逆に保守性が悪化

そこで本記事では、次のような「2 段階アプローチ」で解決していきます。

  1. DB では「日付 × グループ別の売上集計」だけを行う(SQL に翻訳しやすい部分)
  2. 取得した結果リストに対して、アプリ側(メモリ上)で 歩合ロジックを適用する

前提となるテーブル構成とモデル

まず、典型的なテーブル構成(あるいはエンティティ構成)を整理しておきます。

テーブル(エンティティ)イメージ

テーブル主なカラム説明
Productsproductid
groupid
price
商品マスタ。商品ごとにグループと単価を保持。
Sellssellid
productid
Date
qty
売上明細。どの商品がいつ何個売れたか。
CommissionStructurescommissionid
groupid
Targetvalue
discount
グループごとの歩合テーブル。目標売上と対応する歩合額(または率)。

C# のエンティティクラスとしては次のようなイメージです。

public class Product
{
    public int ProductId { get; set; }
    public int GroupId { get; set; }
    public decimal Price { get; set; }
}

public class Sell
{
    public int SellId { get; set; }
    public int ProductId { get; set; }
    public DateTime Date { get; set; }
    public int Qty { get; set; }
}

public class CommissionStructure
{
    public int CommissionId { get; set; }
    public int GroupId { get; set; }
    public decimal Targetvalue { get; set; }
    public decimal Discount { get; set; }
}

public class Report
{
    public int ReportId { get; set; }
    public DateTime Date { get; set; }
    public decimal Salevalue { get; set; }
    public decimal Commission { get; set; }
}

よくある LINQ 実装と EF Core 変換エラー

まず「やりがちだけど危険な」LINQ を見てみます。イメージとしては、1 クエリで全部やろうとする書き方です。

// あくまで「ありがちなイメージ」で、推奨コードではありません
var query =
    from s in context.Sells
    join p in context.Products on s.ProductId equals p.ProductId
    group new { s, p } by new { s.Date.Date, p.GroupId } into g
    select new
    {
        Date = g.Key.Date,
        GroupId = g.Key.GroupId,
        TotalSaleForGroup = g.Sum(x => x.s.Qty * x.p.Price),
        // ★ GroupBy の中でさらにサブクエリ
        BestDiscount =
            (from cs in context.CommissionStructures
             where cs.GroupId == g.Key.GroupId
                && g.Sum(x => x.s.Qty * x.p.Price) >= cs.Targetvalue
             orderby cs.Targetvalue descending
             select cs.Discount
            ).FirstOrDefault()
    };

このようなクエリは、次のような問題を引き起こしがちです。

  • EF Core が GroupBy 内のサブクエリや集計を正しく SQL に変換できず、実行時に例外になる
  • DB から見ると非常に複雑な SQL になり、インデックスが効きにくい・パフォーマンスが安定しない
  • 歩合テーブルに該当行がない場合、FirstOrDefault() が 0 ではなく null を返し、後続処理で null 例外 が起きる

これを避けるために、「DB に投げる部分はシンプルに、ビジネスロジックはアプリ側で」というポリシーを採用します。

解決方針:DB 集計とビジネスロジックを分ける 2 段階アプローチ

今回紹介するアプローチは非常にシンプルです。

段階処理場所役割
Step 1DB(EF Core で SQL 実行)日付 × グループ別の売上金額を集計する
Step 2アプリ側(メモリ上の LINQ)歩合テーブルをもとに最高ランクの歩合だけを選び、日付ごとに合計する

この分離をするだけで、次のようなメリットがあります。

  • Step 1 のクエリは 単純な JOIN + GROUP BY だけなので、EF Core が確実に SQL に変換できる
  • 歩合ロジックを C# の世界で書けるので、テストや仕様変更に強い
  • 必要に応じて Step 1 を ビューやストアドプロシージャに移すことも容易

Step 1:DB で日付 × グループ別売上だけを集計する

まず、売上明細と商品マスタを JOIN し、日付単位・グループ単位で売上金額を集計します。

var dailyGroupSales = context.Sells
    .Join(context.Products,
          s => s.ProductId,
          p => p.ProductId,
          (s, p) => new
          {
              s.Date,
              p.GroupId,
              Sale = s.Qty * p.Price
          })
    .GroupBy(x => new { Date = x.Date.Date, x.GroupId })
    .Select(g => new
    {
        Date = g.Key.Date,
        GroupId = g.Key.GroupId,
        TotalSaleForGroup = g.Sum(x => x.Sale)
    })
    .ToList(); // ★ ここで SQL が実行され、メモリ上のリストになる

ここで大事なのは以下の点です。

  • .Date.Date を使って、時刻部分を切り捨てて「日付」でグルーピングしている
  • SELECT では「日付・グループ ID・売上合計」のみを持つシンプルな匿名型に変換
  • .ToList() で一旦メモリに載せることで、ここから先は純粋な in-memory LINQ になる

この dailyGroupSales の中身は、例えば次のようになります。

IndexDateGroupIdTotalSaleForGroup
02025-02-011500
12025-02-012700
22025-02-0211,000
32025-02-0232,000

この段階では歩合テーブル (CommissionStructures) を一切参照していないのがポイントです。

Step 2:メモリ上で歩合テーブルを使ってレポートを組み立てる

次に、dailyGroupSales と commissionStructures を使って最終的な日次レポートを組み立てます。

ここから先は 完全に C# の世界です。EF Core の制約をあまり気にせず、素直に LINQ が書けます。

var commissionStructures = context.CommissionStructures.ToList();

var report = dailyGroupSales
    .GroupBy(s => s.Date)
    .Select((g, idx) =>
    {
        // その日の全グループ分の歩合を合計
        var commission = g.Sum(gs =>
        {
            var best = commissionStructures
                .Where(cs => cs.GroupId == gs.GroupId
                            && gs.TotalSaleForGroup >= cs.Targetvalue)
                .OrderByDescending(cs => cs.Targetvalue) // 最高ランクから順に
                .FirstOrDefault(); // なければ null

            // null 安全。未達成の場合は歩合 0
            return best?.Discount ?? 0m;
        });

        return new Report
        {
            ReportId  = idx + 1,
            Date      = g.Key,
            Salevalue = g.Sum(x => x.TotalSaleForGroup),
            Commission= commission
        };
    })
    .OrderBy(r => r.Date)
    .ToList();

ここでやっていることを分解すると、次のようになります。

  1. dailyGroupSales を日付でグルーピング(1 グループ = 1 日)
  2. その日の中に含まれる「グループ別売上」に対して、歩合テーブルから「達成している目標値のうち最高のもの」を 1 件選ぶ
  3. 日付内の全グループの歩合を合計し、Report オブジェクトとして返す

最高ランクの歩合を選ぶロジックの詳細

最も重要な部分は次のブロックです。

var best = commissionStructures
    .Where(cs => cs.GroupId == gs.GroupId
                && gs.TotalSaleForGroup >= cs.Targetvalue)
    .OrderByDescending(cs => cs.Targetvalue)
    .FirstOrDefault();

このロジックは下記のように理解できます。

  • Where: 同じグループ ID で、かつ「売上合計 >= 目標値」の行だけを抽出
  • OrderByDescending: 目標値の大きい順(=ランクの高い順)に並べ替え
  • FirstOrDefault(): 一番目(最高ランク)のみ取得。該当がなければ null

そして、歩合額自体は次のように null 安全に扱います。

return best?.Discount ?? 0m;
  • best?.Discount … best が null なら null、そうでなければ Discount
  • ?? 0m … 左側が null の場合は 0(decimal 型のリテラル)

これにより、目標未達のグループは自動的に歩合 0 として扱われます。
これが「最高ランクだけを合計する」という要件を素直に表現した部分です。

サンプル:グループ別歩合テーブルと判定イメージ

歩合テーブルが次のようになっているとします。

GroupIdTargetvalueDiscount
150010
11,00030
270020

ある日付のグループ別売上が次のような場合:

GroupIdTotalSaleForGroup達成ランク採用される Discount
11,200500 と 1,000 を達成30(最高ランク 1,000 の分)
2600700 に届かず未達0

上記ロジックにより、GroupId = 1 では Targetvalue = 1,000 の行だけが採用され、GroupId = 2 は未達扱いとなります。

最終出力イメージ

この 2 段階処理の結果、日次レポートは次のような形で得られます。

reportid日付売上合計歩合合計
12025-02-011,20020
22025-02-023,000300

ここでの「歩合合計」は、日付内の全グループについて「そのグループが達成した最高ランクの歩合」を合計したものです。

応用:段階累積型の歩合を計算したい場合

これまでの例は「達成したランクのうち最高ランクだけを採用」するケースでした。
一方で、ビジネスによっては次のような「段階累積型」の歩合を採用したい場合があります。

  • 500 以上で 10
  • 1,000 以上でさらに 20(合計 30)

この場合は、Where で条件を満たした複数行の Discount を Sum するだけで対応できます。

var commission = g.Sum(gs =>
{
    var totalDiscount = commissionStructures
        .Where(cs => cs.GroupId == gs.GroupId
                    && gs.TotalSaleForGroup >= cs.Targetvalue)
        .Sum(cs => cs.Discount);

    return totalDiscount;
});

違いは次の通りです。

方式取得方法イメージ
最高ランクのみOrderByDescending().FirstOrDefault()「その日に達成したランクの中で一番上だけ」
段階累積型Where() した結果を Sum()「達成したランクをすべて加算」

このように、歩合のビジネスルールが変わっても、Step 2 のロジックを書き換えるだけで対応できます。DB 側の構造は変える必要がありません。

応用:売上ゼロの日もレポートに出したい場合

実務では、「売上が 0 の日も日次レポート一覧に表示したい」という要件がよくあります。
その場合は、あらかじめ期間の全日付リストを用意しておき、GroupJoin で売上データを結合するとシンプルに書けます。

var startDate = new DateTime(2025, 2, 1);
var endDate   = new DateTime(2025, 2, 28);

var allDates = Enumerable.Range(0, (endDate - startDate).Days + 1)
    .Select(offset => startDate.AddDays(offset))
    .ToList();

var reportWithZero = allDates
    .GroupJoin(
        dailyGroupSales,
        d => d.Date,
        s => s.Date,
        (d, salesForDate) => new { Date = d, Sales = salesForDate })
    .Select((x, idx) =>
    {
        var commission = x.Sales.Sum(gs =>
        {
            var best = commissionStructures
                .Where(cs => cs.GroupId == gs.GroupId
                            && gs.TotalSaleForGroup >= cs.Targetvalue)
                .OrderByDescending(cs => cs.Targetvalue)
                .FirstOrDefault();

            return best?.Discount ?? 0m;
        });

        var totalSale = x.Sales.Sum(gs => gs.TotalSaleForGroup);

        return new Report
        {
            ReportId  = idx + 1,
            Date      = x.Date,
            Salevalue = totalSale,
            Commission= commission
        };
    })
    .OrderBy(r => r.Date)
    .ToList();

このやり方のメリットは次の通りです。

  • 売上が 1 件もない日でも、必ず 1 行の Report を生成できる
  • 売上ゼロの日は Sales が空のコレクションなので、Sum() は自動的に 0 となる
  • 期間指定を変えることで、容易に「月次・期間レポート」を切り替えられる

大量データを扱うときのパフォーマンスと設計のコツ

日次売上や歩合計算は、扱うデータ量が増えやすい領域です。
ここでは、パフォーマンスと保守性の観点で押さえておきたいポイントを整理しておきます。

1. Step 1 をビューやストアドプロシージャに切り出す

売上データが数百万件を超えるような案件では、Step 1 の集計を DB 側のビューやストアドプロシージャに切り出すと効果的です。

  • DB エンジニアがインデックス設計や実行計画をチューニングしやすい
  • アプリ側の LINQ は FromSql... またはビューエンティティを通じてシンプルに呼び出すだけ
  • 歩合ロジックの変更など「アプリ寄りの変更」と切り分けられる

2. 読み取り専用であれば AsNoTracking を活用

レポート用の読み取り処理は、通常トラッキング(変更監視)は不要です。EF Core の AsNoTracking() を付けるだけでメモリ使用量と速度を改善できるケースがあります。

var dailyGroupSales = context.Sells
    .AsNoTracking()
    .Join(...)
    ...
    .ToList();

3. 日付の扱い(タイムゾーン・DateTimeKind)に注意

DateTime の .Date プロパティは「ローカル時刻」として扱われるため、タイムゾーンが絡むシステムでは注意が必要です。

  • DB に UTC を保存している場合、アプリ側でローカルタイムに変換してから集計するかどうかを明確に決める
  • .NET 6 以降であれば DateOnly type の利用も検討する

4. メモリ上の歩合テーブルをキャッシュする

commissionStructures は頻繁に変わらないマスタ情報であることが多いため、アプリ内でキャッシュしたり、DI コンテナ経由でシングルトン的に扱うことで、DB への無駄なアクセスを減らせます。

  • アプリ起動時にキャッシュしておく
  • 管理画面から更新されたらキャッシュをクリアする仕組みを用意

テストしやすい設計にするための分離テクニック

歩合ロジックはビジネス要求が変わりやすい部分なので、テストを書きやすい構造にしておくと後々楽になります。

おすすめは、「DB アクセス部分」と「ビジネスロジック部分」をメソッドレベルで分離することです。

// DB からはあくまで「日付 × グループ別売上」のみを取得
public List<DailyGroupSale> GetDailyGroupSales(MyDbContext context, DateTime from, DateTime to)
{
    return context.Sells
        .Join(context.Products,
              s => s.ProductId,
              p => p.ProductId,
              (s, p) => new { s.Date, p.GroupId, Sale = s.Qty * p.Price })
        .Where(x => x.Date >= from && x.Date <= to)
        .GroupBy(x => new { Date = x.Date.Date, x.GroupId })
        .Select(g => new DailyGroupSale
        {
            Date = g.Key.Date,
            GroupId = g.Key.GroupId,
            TotalSaleForGroup = g.Sum(x => x.Sale)
        })
        .ToList();
}

public class DailyGroupSale
{
    public DateTime Date { get; set; }
    public int GroupId { get; set; }
    public decimal TotalSaleForGroup { get; set; }
}

そして、歩合ロジックは「純粋な C# メソッド」として切り出します。

public List<Report> BuildDailyReport(
    List<DailyGroupSale> dailyGroupSales,
    List<CommissionStructure> commissionStructures)
{
    return dailyGroupSales
        .GroupBy(s => s.Date)
        .Select((g, idx) =>
        {
            var commission = g.Sum(gs =>
            {
                var best = commissionStructures
                    .Where(cs => cs.GroupId == gs.GroupId
                                && gs.TotalSaleForGroup >= cs.Targetvalue)
                    .OrderByDescending(cs => cs.Targetvalue)
                    .FirstOrDefault();

                return best?.Discount ?? 0m;
            });

            return new Report
            {
                ReportId  = idx + 1,
                Date      = g.Key,
                Salevalue = g.Sum(x => x.TotalSaleForGroup),
                Commission= commission
            };
        })
        .OrderBy(r => r.Date)
        .ToList();
}

こうしておくと、ユニットテストでは DB を使わずに:

  1. dailyGroupSales にテスト用データを好きなだけ詰める
  2. commissionStructures もテストケースごとに用意する
  3. BuildDailyReport の戻り値を検証する

という形で、歩合ロジックのパターンを網羅的に検証できます。

よくあるつまずきポイントとその回避策

最後に、実装時によくハマるポイントと対策をまとめておきます。

つまずきポイント原因回避策
エラー:「LINQ 式を SQL に変換できません」GroupBy 内でサブクエリや関数呼び出しを多用DB 向け LINQ では「JOIN + GROUP BY +集計」に限定し、それ以外はメモリで行う
NullReferenceException(best が null)目標未達のケースを想定していないbest?.Discount ?? 0m のように null 安全な書き方を徹底する
売上が 0 の日がレポートに出てこない売上が存在する日付だけでグルーピングしている事前に全日付を生成し、GroupJoin で結合する
レポート作成が遅い大量データをすべてアプリに持ってきている期間を絞る・ビュー/ストアドに移す・インデックスを見直す

まとめ:DB とアプリの役割分担が LINQ 成功のカギ

本記事では、ASP.NET (C#)/Entity Framework Core 環境で、

  • 商品グループごとの日次売上を集計し
  • その日に達成した「最高ランクの歩合」だけを合計して日次レポートを出す

という要件を、2 段階アプローチで実現する方法を紹介しました。

  • Step 1:DB では JOIN と GROUP BY のみを行い、「日付 × グループ別売上」を取得する
  • Step 2:アプリ側で歩合テーブルを元に「最高ランク」または「段階累積」の計算を行う

ポイントを改めて整理すると、次のようになります。

  • 複雑なロジックを無理に 1 本の LINQ で書かず、SQL に向く処理と C# に向く処理を分ける
  • OrderByDescending() + FirstOrDefault() で「最高ランク」をシンプルに表現
  • ?. と ?? 0m で null 安全に歩合 0 を保証し、例外を防ぐ
  • 売上ゼロの日や段階累積型の歩合にも柔軟に対応できる構造にしておく

「DB で出来る集計」と「アプリでしか出来ないビジネスロジック」を意識的に分離することで、EF Core の SQL 変換エラーを避けながら、保守性とパフォーマンスに優れた日次売上レポートを実装できます。
既存のレポート処理が複雑になっている場合は、一度この 2 段階アプローチに分解して見直してみるのがおすすめです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次