0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python で店舗経営分析のプレゼン資料を作成する

0
Posted at

小売チェーンの月次経営会議には決まった資料がある。今月はどれだけ売れたか、どの店が伸びてどの店が落ちたか、売れている物の構成が変わったか、そして会議で何を決めるか。データの出所も決まっている——営業企画部が毎月 BI から書き出す《店舗営業台帳》で、シートは 2 枚。店舗売上(店舗名/売上高/前年同期比/粗利率/客単価)と、品目構成(品目/売上高/構成比)である。台帳には文章が 1 行もなく、資料に載る結論はすべてこの 2 枚から導かれる。

台帳を資料に変える手作業は、表をスライドへ貼り、頁ごとにグラフを挿入し、種類を選び、データ範囲をドラッグで指定し、結論の文章を数字を見ながら打ち直す、という流れになる。問題は最後の段で、グラフ上の数字と文章中の数字が二系統になることにある。片方を直して片方を忘れれば「グラフは 550.8 万円、本文は 55.08 万円」という食い違いが起きる。店舗数が 6 から 7 に増えれば、グラフの参照範囲も結論の順位も全部引き直しになる。しかも毎月同じ版面を保つ必要があり、手作業では 30 分はかかる。

本記事では Free Spire.Presentation for Python を使い、この資料を「台帳から直接生成する」形にする。構成は会議で答える問いの順に沿わせる——全体規模は棒グラフ、伸び率は第 2 軸を使った複合グラフ、品目構成はドーナツグラフ、最後に要点 5 行を 1 頁へまとめる。グラフのデータと結論の数字は同じ読み込み結果から取るため、二つの口径が分岐しない。

pip install spire.presentation.free spire.xls.free

グラフのデータを Excel の台帳から取るので、spire.presentation.free と spire.xls.free の 2 つが要る。前者でプレゼン資料を作り、後者で台帳を読む。


台帳の口径と読み込み

サンプルの《店舗営業台帳 2026 年 9 月》は 2 枚のシートで構成される。

シート 構造
店舗売上 3 行目が見出し、4 行目から明細:店舗名/売上高(万円)/前年同期比/粗利率/客単価(円)。末尾は「合計 / 平均」
品目構成 3 行目が見出し、4 行目から明細:品目/売上高(万円)/構成比

読み込みでは範囲を推測せず、「見出しが 3 行目、合計行まで」という約束だけを手掛かりに順に読む。

from spire.xls import Workbook
from spire.presentation.common import RectangleF
from spire.presentation import (Presentation, FileFormat, ChartType, ShapeType,
                                TextParagraph, TextFont, TextAlignmentType,
                                FillFormatType, ChartDataPoint, Color)

TITLE_FONT = "Meiryo"
MONTH = "2026 年 9 月"
LEDGER = "店舗営業台帳_2026年9月.xlsx"
DECK = "店舗経営分析_2026年9月.pptx"


def read_ledger(path):
    wb = Workbook()
    wb.LoadFromFile(path)
    s1, s2 = wb.Worksheets[0], wb.Worksheets[1]
    stores = []
    r = 4
    while s1.Range[r, 1].Text and "合計" not in s1.Range[r, 1].Text:
        stores.append((s1.Range[r, 1].Text,
                       s1.Range[r, 2].NumberValue,
                       s1.Range[r, 3].NumberValue,
                       s1.Range[r, 4].NumberValue))
        r += 1
    cates = []
    r = 4
    while s2.Range[r, 1].Text:
        cates.append((s2.Range[r, 1].Text, s2.Range[r, 2].NumberValue))
        r += 1
    wb.Dispose()
    return stores, cates

ループの終了条件に使うのは「セルが空」または「文字列に合計を含む」で、行番号は決め打ちしない。明細の行数は店舗数で決まるため、行番号を固定すると店舗が増減したときに必ず読み違える。内容で判定すれば自動で追随する。取り出すのは NumberValue(数値)で、前年同期比と粗利率は台帳上で小数として保持されている(0.128 は 12.8%)。書式はグラフと文章の側で整える。

店舗営業台帳の 2 シート

プレゼン資料そのものを先に用意する。表紙は濃色の帯に表題を載せ、以降の頁はその後に追加していく。表紙の文字も塗りなしの矩形に置き、色と文字サイズを段落ごとに指定する。

pres = Presentation()


def cover_text(slide, text, rect, size, rgb):
    sh = slide.Shapes.AppendShape(ShapeType.Rectangle, rect)
    sh.Fill.FillType = FillFormatType.none
    sh.Line.FillType = FillFormatType.none
    para = TextParagraph()
    para.Text = text
    para.Alignment = TextAlignmentType.Center
    para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
    para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
    para.TextRanges[0].FontHeight = size
    para.TextRanges[0].Fill.FillType = FillFormatType.Solid
    para.TextRanges[0].Fill.SolidColor.Color = rgb
    sh.TextFrame.Paragraphs.Append(para)
    return sh


cover = pres.Slides[0]
band = cover.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(0, 150, 720, 390))
band.Fill.FillType = FillFormatType.Solid
band.Fill.SolidColor.Color = Color.FromArgb(255, 18, 38, 62)
band.Line.FillType = FillFormatType.none
cover_text(cover, "%s 店舗経営分析" % MONTH,
           RectangleF.FromLTRB(40, 208, 680, 272), 36, Color.get_White())
cover_text(cover, "株式会社北斗リテール · 営業企画部  月次経営会議",
           RectangleF.FromLTRB(40, 286, 680, 326), 17, Color.FromArgb(255, 170, 190, 210))

問い 1:今月は全体でどれだけ売れたか

1 頁目は規模を答える。6 店舗の売上高を集合縦棒グラフで並べ、各棒に数値ラベルを付ければ、棒の高さを目測する必要がなくなる。

グラフ作成とデータ投入の共通処理を関数に切り出し、後の 3 枚でも使い回す。

def fill_chart_data(chart, categories, series_names, series_values):
    for c, name in enumerate(series_names):
        chart.ChartData[0, c + 1].Text = name
    for i, cat in enumerate(categories):
        chart.ChartData[i + 1, 0].Text = cat
        for c, values in enumerate(series_values):
            chart.ChartData[i + 1, c + 1].NumberValue = values[i]
    col = chr(ord("A") + len(series_names))
    chart.Series.SeriesLabel = chart.ChartData["B1", "%s1" % col]
    chart.Categories.CategoryLabels = chart.ChartData["A2", "A%d" % (len(categories) + 1)]
    for c in range(len(series_names)):
        letter = chr(ord("A") + c + 1)
        chart.Series[c].Values = chart.ChartData["%s2" % letter, "%s%d" % (letter, len(categories) + 1)]

データの投入は 2 段階で、どちらも省けない。前半で分類名と各系列の数値を chart.ChartData という表オブジェクトへ書き込む(行・列は 0 起点)。後半でグラフと表の3 か所を結び付ける。系列名は 1 行目の 2 列目以降、分類ラベルは 0 列目の 2 行目以降、各系列の値は対応する列。ChartData に書いただけで結び付けを行わなければ、保存されるグラフは空になる。データ源がどの表を指すのか決まっていないためである。

グラフ頁の見出しはプレースホルダーではなく、塗りも枠線も無い矩形を文字枠として使う。プレースホルダーは版面をマスターから継承するため、位置と文字サイズが上書きされる。

def title_of(slide, text, subtitle=None):
    box = slide.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(40, 20, 680, 96))
    box.Fill.FillType = FillFormatType.none
    box.Line.FillType = FillFormatType.none
    para = TextParagraph()
    para.Text = text
    para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
    para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
    para.TextRanges[0].FontHeight = 24
    box.TextFrame.Paragraphs.Append(para)
    if subtitle:
        para2 = TextParagraph()
        para2.Text = subtitle
        para2.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
        para2.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
        para2.TextRanges[0].FontHeight = 13
        box.TextFrame.Paragraphs.Append(para2)
    return box


stores, cates = read_ledger(LEDGER)
names = [s[0] for s in stores]
sales = [s[1] for s in stores]
growth = [s[2] for s in stores]
margin = [s[3] for s in stores]
total = sum(sales)
top = max(stores, key=lambda x: x[1])
worst = min(stores, key=lambda x: x[1])
up = [s for s in stores if s[2] > 0]
neg = [s for s in stores if s[2] < 0]

s2 = pres.Slides.Append()
title_of(s2, "一、店舗別の売上高(万円)",
         "合計 %.1f 万円、最高 %s/最低 %s" % (total, top[0], worst[0]))
ch1 = s2.Shapes.AppendChartInit(ChartType.ColumnClustered,
                                RectangleF.FromLTRB(60, 100, 660, 470), False)
ch1.ChartTitle.TextProperties.Text = "2026 年 9 月 店舗別売上高"
ch1.ChartTitle.TextProperties.IsCentered = True
ch1.ChartTitle.Height = 34
ch1.HasTitle = True
fill_chart_data(ch1, names, ["売上高"], [sales])
ch1.Series[0].DataLabels.LabelValueVisible = True
ch1.Series[0].DataLabels.NumberFormat = "#,##0.0"
ch1.PrimaryValueAxis.NumberFormat = "#,##0.0"
ch1.GapWidth = 120

GapWidth は棒の間隔で、値を小さくすると棒が太くなる。既定値では 6 本の棒が細く見え、数値ラベルを棒の上に置く余裕がなくなる。

フォントは LatinFont と EastAsianFont の両方を指定する。LatinFont だけを指定すると、欧文は指定どおりに描画され、日本語は描画環境の既定フォントへフォールバックする。別の端末で開いたときに見た目が変わる。日本語環境なら Meiryo や 游ゴシック、中国語環境の資料なら Microsoft YaHei のように、その資料を開く環境に存在する書体名を選ぶ。

副見出しの 3 つの数字は total、top、worst から計算しており、手書きの定数ではない。これは全頁で統一した方針で、スライドに出る数字はすべてコードで算出する。

店舗別売上高の頁

問い 2:どこが伸び、どこが落ちたか

棒グラフでは絶対額の大小は分かるが、誰が伸びているかは分からない。2 頁目は単位の異なる 2 つの指標を同時に見る。売上高は万円単位(最大 152.6)、前年同期比は割合(±22% の範囲)。同じ軸に載せると、比較の線は x 軸に貼り付いた直線に潰れる。

そこで前年同期比を第 2 軸へ移し、2 系統の目盛りを独立させる。

s3 = pres.Slides.Append()
title_of(s3, "二、売上高と前年同期比", "前年超えは %d 店舗、前年割れは %d 店舗"
         % (len(up), len(neg)))
ch2 = s3.Shapes.AppendChartInit(ChartType.ColumnClustered,
                                RectangleF.FromLTRB(60, 100, 660, 470), False)
ch2.ChartTitle.TextProperties.Text = "売上高(棒)と前年同期比(折れ線・右軸)"
ch2.ChartTitle.TextProperties.IsCentered = True
ch2.ChartTitle.Height = 34
ch2.HasTitle = True
fill_chart_data(ch2, names, ["売上高", "前年同期比"], [sales, growth])
ch2.Series[1].Type = ChartType.LineMarkers        # 2 番目の系列をマーカー付き折れ線にする
ch2.Series[1].UseSecondAxis = True                # この系列を第 2 軸に載せる
ch2.SecondaryValueAxis.NumberFormat = "0.0%"
ch2.SecondaryValueAxis.MajorGridTextLines.FillType = FillFormatType.none
ch2.Series[0].DataLabels.LabelValueVisible = True
ch2.Series[0].DataLabels.NumberFormat = "#,##0.0"
ch2.PrimaryValueAxis.NumberFormat = "#,##0.0"
ch2.GapWidth = 140

複合グラフの要点は 3 行だけである。系列を Series[1].Type で ChartType.LineMarkers に変え、UseSecondAxis で第 2 軸へ載せ、第 2 軸の NumberFormat を百分率にする。グラフ自体は ColumnClustered で作成したままで、系列の種類は個別に上書きできる。複合グラフ専用の種類が要らないのはこのためである。

第 2 軸には既定で横の目盛線が入る。主軸の目盛線と重なると図面が汚れるので切っておく——MajorGridTextLines.FillType = FillFormatType.none。サンプルでは 6 店舗のうち 4 店舗が前年超え、2 店舗が前年割れで、折れ線が 0% をまたいで上下し、落ちている 2 店舗が一目で分かる。

売上高と前年同期比の複合グラフ

問い 3:売れている物の構成は変わったか

3 頁目は品目の構成で、割合を見るためにドーナツグラフを使う。ドーナツグラフ(ChartType.Doughnut)は既定で全扇区が同じ色になるため、品目の意味に合わせて配色するには点ごとに設定する。

s4 = pres.Slides.Append()
cate_names = [c[0] for c in cates]
cate_vals = [c[1] for c in cates]
cate_total = sum(cate_vals)
title_of(s4, "三、営業品目の構成", "生鮮と日用品で全体の %.1f%% を占める"
         % ((cate_vals[0] + cate_vals[1]) / cate_total * 100))
ch3 = s4.Shapes.AppendChartInit(ChartType.Doughnut,
                                RectangleF.FromLTRB(90, 100, 630, 470), False)
ch3.ChartTitle.TextProperties.Text = "品目別売上高の構成比"
ch3.ChartTitle.TextProperties.IsCentered = True
ch3.ChartTitle.Height = 34
ch3.HasTitle = True
fill_chart_data(ch3, cate_names, ["売上高"], [cate_vals])
palette = [Color.FromArgb(255, 214, 69, 65), Color.FromArgb(255, 240, 138, 76),
           Color.FromArgb(255, 247, 202, 92), Color.FromArgb(255, 122, 176, 138),
           Color.FromArgb(255, 88, 148, 200), Color.FromArgb(255, 168, 168, 176)]
for i, _ in enumerate(cate_names):
    dp = ChartDataPoint(ch3.Series[0])
    dp.Index = i
    ch3.Series[0].DataPoints.Add(dp)
    ch3.Series[0].DataPoints[i].Fill.FillType = FillFormatType.Solid
    ch3.Series[0].DataPoints[i].Fill.SolidColor.Color = palette[i % len(palette)]
ch3.Series[0].DataLabels.PercentValueVisible = True
ch3.Series[0].DataLabels.LabelValueVisible = False
ch3.DoughnutHoleSize = 58

順序は入れ替えられない。まず ChartDataPoint を作り、Index を設定して DataPoints コレクションへ Add し、その後で DataPoints[i] として添字でアクセスして塗りを設定する。Add の前に添字で触ると範囲外になる。配色表は i % len(palette) で剰余を取るので、品目が増えても色を循環して使い、リスト長を超えて例外になることがない。

ラベルは百分率だけを残し、絶対額は切る。ドーナツグラフの各扇区へ 2 つの数字を載せると込み入るためで、具体的な金額は台帳を見れば分かる。会議で必要なのは構成比である。

営業品目構成のドーナツグラフ

まとめ頁:会議で読む 5 行

最後の頁はグラフを出さず、そのまま読める 5 行を出す。内容も読み込み結果から導くため、前の 3 頁と同じ口径を共有する。

s5 = pres.Slides.Append()
title_of(s5, "四、今月の要点", "集計基準:売上高は税込実収、前年同期比は 2025 年 9 月対比")
best_margin = max(stores, key=lambda x: x[3])
best_growth = max(stores, key=lambda x: x[2])
lines = [
    "規模:全店合計は %.1f 万円。%s が最も多く %.1f 万円。" % (total, top[0], top[1]),
    "成長:前年超え %d 店舗、前年割れ %d 店舗。伸び率の首位は %s(%.1f%%)。"
    % (len(up), len(neg), best_growth[0], best_growth[2] * 100),
    "要対応:%s が前年割れ。次回会議までに立て直し案の提出が必要。"
    % "、".join(s[0] for s in neg),
    "構成:生鮮が %.1f%% で集客の柱。酒類と家庭用洗剤は合わせて %.1f%%。"
    % (cate_vals[0] / cate_total * 100,
       (cate_vals[3] + cate_vals[4]) / cate_total * 100),
    "収益性:%s の粗利率 %.1f%% が全店で最高。売場と品揃えの手本として横展開する。"
    % (best_margin[0], best_margin[3] * 100),
]

5 行の構成は 5 つの問いに対応する——規模、成長、要対応、構成、収益性。主語も数字も変数で、固定なのは文型だけである。この「文型は固定、数字は動的」という組み合わせが自動化に向く理由で、同じ文型が 10 月・11 月にも使い回せ、差し替わるのは台帳だけになる。

文字塊は薄い灰色の矩形に載せ、段落を順に追加し、段落の間に空の段落を挟んで行間を作る。

box = s5.Shapes.AppendShape(ShapeType.Rectangle, RectangleF.FromLTRB(60, 110, 660, 470))
box.Fill.FillType = FillFormatType.Solid
box.Fill.SolidColor.Color = Color.FromArgb(255, 246, 248, 251)
box.Line.FillType = FillFormatType.none
for text in lines:
    para = TextParagraph()
    para.Text = "・" + text
    para.TextRanges[0].LatinFont = TextFont(TITLE_FONT)
    para.TextRanges[0].EastAsianFont = TextFont(TITLE_FONT)
    para.TextRanges[0].FontHeight = 16
    para.LeftMargin = 14
    box.TextFrame.Paragraphs.Append(para)
    box.TextFrame.Paragraphs.Append(TextParagraph())

引数なしの TextParagraph() が空の段落で、段落間の余白として使う。SpaceAfter を調整するより挙動が読みやすく、長い文が折り返したときの見た目も安定する。サンプルで最も長い行(前年割れの 2 店舗を並べる「要対応」)は幅 600pt で 2 行に折り返すが、行の高さは文字サイズで決まるため次の段落へはみ出さない。

今月の要点頁

生成結果の階層を照合する

5 頁を組み終えたらファイルへ保存する。

pres.SaveToFile(DECK, FileFormat.Pptx2013)
pres.Dispose()

資料を生成したら「各頁にあるべきものが揃っているか」を確かめる。生成したファイルを読み直し、頁ごとに図形の数を並べ、グラフとそれ以外を区別する。

from spire.presentation import IChart

chk = Presentation()
chk.LoadFromFile(DECK)
print("スライド枚数:", len(chk.Slides))
for i, sl in enumerate(chk.Slides):
    kinds = ["グラフ" if isinstance(sh, IChart) else "テキスト" for sh in sl.Shapes]
    print("  p%d:図形 %d 個 -> %s" % (i + 1, len(sl.Shapes), "、".join(kinds)))
スライド枚数: 5
  p1:図形 3 個 -> テキスト、テキスト、テキスト
  p2:図形 2 個 -> テキスト、グラフ
  p3:図形 2 個 -> テキスト、グラフ
  p4:図形 2 個 -> テキスト、グラフ
  p5:図形 2 個 -> テキスト、テキスト

図形の種類を判定するには isinstance(sh, IChart) を使う。ShapeType 列挙体に「グラフ」を表すメンバーは無く、列挙体で比較しようとすると属性エラーになる。この出力は 3 つのことを同時に確認している。頁数が 5 であること、グラフの 3 頁にそれぞれグラフが 1 つ載っていること、まとめ頁に余計なグラフが増えていないこと。

チャート関連のクラスとメンバー

クラス / メンバー 役割
Presentation / Presentation.Slides プレゼン資料と頁コレクション。Append() で頁を追加
Slide.Shapes.AppendChartInit 指定した矩形へグラフを作成する。種類は ChartType
Chart.ChartData グラフのデータ表。[行, 列] に Text または NumberValue を書く
Chart.Series.SeriesLabel 系列名の範囲。凡例の文字列を決める
Chart.Categories.CategoryLabels 分類ラベルの範囲。x 軸の項目を決める
Chart.Series[i].Values i 番目の系列の数値範囲
Chart.Series[i].Type 系列ごとにグラフ種別を上書きする。複合グラフの基礎
Chart.Series[i].UseSecondAxis 系列を第 2 軸へ載せる
Chart.PrimaryValueAxis / SecondaryValueAxis 主軸と第 2 軸。NumberFormat で目盛りの書式、MajorGridTextLines で目盛線
Chart.Series[i].DataLabels データラベル。LabelValueVisible / PercentValueVisible / NumberFormat
Chart.GapWidth 棒の間隔。棒の太さに影響する
Chart.DoughnutHoleSize ドーナツの穴の大きさ。サンプルは 58
ChartDataPoint / Chart.Series[i].DataPoints 扇区ごとの塗りを設定する。Add してから添字で触る
Slide.Shapes.AppendShape テキスト枠などの図形を挿入する。FillFormatType.none と Line.FillType を組み合わせると文字塊だけを置ける
TextParagraph / TextFont 段落とフォント。和文と欧文は LatinFont と EastAsianFont を別々に指定する
IChart 図形がグラフかどうかを判定するインターフェース

台帳が替わったとき

この処理が台帳に求めるのは「見出しが 3 行目、明細が連続、末尾が合計行」という条件だけである。別の月のデータへ替えるのは台帳のパスを差し替えるだけで済み、店舗数や品目数が変わってもコードの変更は要らない。読み込みのループもグラフの範囲指定も実際の行数から計算する。

ただし置き換わるのは「数字をグラフに描き、要点を文に組む」部分だけである。まとめ頁の文型は数字の正しさと口径の一致を保証するが、その表現が適切かどうかまでは保証しない。ある店舗が落ちた理由が商圏の変化なのか売場構成なのかは、会議の前に人が補う必要がある。グラフの種類が固定である点も同じで、その月に客単価の分布を主題にするなら棒グラフもドーナツも要件を満たさず、頁を足すことになる。

広げられる方向としては、まとめ頁の文章を議事メモの下書きとして同時に書き出す、前年同月の台帳も読み込んで第 2 頁を 2 年比較にする、頁ごとの組版パラメータ(位置・文字サイズ・配色)を設定へ切り出して同一データから複数ブランドの様式を出力する、といったものが考えられる。変更は様式の定数と文型に集中し、データの読み込みとグラフの結び付けには影響しない。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?