中級者向け No.20

請求書作成アプリ

顧客・商品を選択してPDF請求書を自動生成するアプリ。reportlabライブラリでPDF出力を実装します。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), reportlab ⏱️ 制作時間: 30〜90分

1. アプリ概要

顧客・商品を選択してPDF請求書を自動生成するアプリ。reportlabライブラリでPDF出力を実装します。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・reportlab で、難易度は ★★★ です。

このアプリは「データベース連携」カテゴリです。データベース連携 GUI は永続化と画面更新を結びつける典型例で、トランザクション・スキーマ設計・UI 反映の三点を一度に学べます。tkinter(標準ライブラリ)・reportlab を活かして実装するこの構造は、他のアプリにも応用が効きます。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

請求書作成アプリ 実行画面(Windows)
実行画面(Windows)
請求書作成アプリ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「📄 PDF出力」ボタン(_export_pdf())・「📊 CSV出力」ボタン(_export_csv())・「💾 保存」ボタン(_save_invoice())・「🆕 新規」ボタン(_new_invoice())・「✏️ 顧客管理」ボタン(_manage_clients())・「➕ 追加」ボタン(_add_line())・「🗑️ 選択行削除」ボタン(_delete_line())・「追加」ボタンで操作
  • イベント <<ComboboxSelected>> から _on_item_select() を実行
  • ttk.Treeview による表形式の一覧表示
  • sqlite3.connect() による SQLite データベース接続と SQL 実行
  • CSV ファイルへの書き出し
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「請求書作成アプリ」・サイズ 900x620 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install reportlab
app20.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import sqlite3
import os
from datetime import datetime, date

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "invoices.db")

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.minsize(900, 620)
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=ttk.Style().lookup("TLabelframe", "background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=ttk.Style().lookup("TLabelframe", "background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=ttk.Style().lookup("TLabelframe", "background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=ttk.Style().lookup("TLabelframe", "background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=ttk.Style().lookup("TLabelframe", "background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=ttk.Style().lookup("TLabelframe", "background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=ttk.Style().lookup("TLabelframe", "background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


if __name__ == "__main__":
    root = tk.Tk()
    app = App20(root)
    root.mainloop()

5. コード解説

請求書作成アプリのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App20 クラスにアプリの全機能をまとめています(メソッド15個・全413行)。__init__ ではタイトル「請求書作成アプリ」とウィンドウサイズ 900x620 を設定します。そのあと _init_db()_build_ui()_load_clients()_load_items() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×8・tk.Label×13(ほかにループでも生成)・ttk.Button×8・ttk.PanedWindowttk.LabelFrame×2・ttk.Style×12・ttk.Entry×4(ほかにループでも生成)・ttk.Combobox×2・tk.Textttk.Spinboxttk.Treeview×3・ttk.Scrollbar×2・tk.Toplevel で構成しています。見出し付きの枠(LabelFrame)は「請求書」「請求書履歴」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「📄 PDF出力」ボタン(_export_pdf())・「📊 CSV出力」ボタン(_export_csv())・「💾 保存」ボタン(_save_invoice())・「🆕 新規」ボタン(_new_invoice())・「✏️ 顧客管理」ボタン(_manage_clients())・「➕ 追加」ボタン(_add_line())・「🗑️ 選択行削除」ボタン(_delete_line())・「追加」ボタンを command= で接続しています。また bind("<<ComboboxSelected>>", ...) から _on_item_select() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

SQLite データベースの接続と実行

sqlite3.connect() でデータベースファイルに接続し、SQL の実行は execute()、確定は commit() で行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:DB_PATH = os.path.join(os.path.dirname(__file__), 'invoices.db')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionImportErrorValueError を捕捉しています。あわせて messagebox.showerror()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app20.py と保存します。外部ライブラリ reportlab を使います。先に pip install reportlab を実行してください。

  2. 2
    クラスの骨格を作る

    App20 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("請求書作成アプリ")geometry("900x620")configure(bg="#f8f9fc")minsize(900, 620) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×8・tk.Label×13(ほかにループでも生成)・ttk.Button×8・ttk.PanedWindowttk.LabelFrame×2・ttk.Style×12・ttk.Entry×4(ほかにループでも生成)・ttk.Combobox×2・tk.Textttk.Spinboxttk.Treeview×3・ttk.Scrollbar×2・tk.Toplevel を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_export_pdf()_export_csv()_save_invoice()_new_invoice()_manage_clients()_add_line()_delete_line()add_client)につなぎます。bind("<<ComboboxSelected>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _export_pdf()(49行)・_manage_clients()(37行)・_export_csv()(21行)など を実装します。SQL の実行結果は execute() の戻り値から取り出します。

  7. 7
    動作確認する

    python app20.py で起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されることを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(DB_PATH)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 「database is locked」というエラーが出る

原因:同じデータベースファイルを別のツール(DB Browser など)で開いたまま書き込むと、SQLite がファイルをロックしてこのエラーになります。

解決法:他のツールでの接続を閉じてから実行してください。書き込みは commit() で確定するまで反映されない点にも注意しましょう。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("900x620") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(900, 620) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは入力した品目と単価を並べて合計するだけで、請求書に必要な記載事項がそろっているかを判断する機能はありません。消費税は subtotal * 0.1(275行)の10%固定で、軽減税率には未対応です。本記事は税務・会計の助言ではありません。実際の取引に使う前に、必要な項目はご自身で確認してください。練習は、最初から入っているサンプル(株式会社ABC・山田商事)と架空の金額で確かめられます。入力した顧客名・住所・メール・電話は、同じフォルダの invoices.db にそのまま残ります。CSV・PDF にも出るので、実在の取引先の情報は入れないでください。PDF 出力には pip install reportlab が必要です(入っていないと起動時に警告が出て、CSV 出力だけが使えます)。

  1. 課題1:保存しても「請求書履歴」に何も出ない

    右側には「請求書履歴」の表があります。ところが、この表へ行を足す処理がコードのどこにもありません。「💾 保存」を押しても、下の帯に 保存完了: ... と出るだけです。

    期待結果:顧客を選び、品目を1つ足して「💾 保存」を押すと、下の帯が 保存完了: INV-20260910-1530 に変わる。右の「請求書履歴」は空のまま。アプリを閉じて開き直しても空のまま。直したあとは、保存した請求書が履歴に1行増え、開き直しても残っている。

    合格条件

    • 「💾 保存」を押すたびに、履歴へ1行増える
    • アプリを起動し直しても、保存済みの請求書が履歴に出る
    • 顧客を選ばずに保存したときは、今までどおり警告が出て履歴は増えない

    ヒント①(どこを触るか): 触るのは2か所です。_save_invoice()self.conn.commit()(296行)の直後と、起動時に履歴を読む処理の追加(35行のあたり)。
    ヒント②(使うもの): self.hist_tree(194行)には、このコードから一度も insert() を呼んでいません。invoicesclientsJOIN clients c ON c.id = i.client_id でつなげば、番号・顧客・発行日の3列を出せます。_load_clients()(212-216行)が self.conn.execute(...).fetchall() の書き方の手本です。
    つまずきやすい点: invoices には金額の列がありません(58-64行)。あるのは invoice_no, client_id, issue_date, due_date, notes, created_at の6つだけです。「金額」まで出すには、合計を入れる列を足すか、明細を保存する表を作る必要があります。CREATE TABLE IF NOT EXISTS は、既にある表を作り直しません。列を足すときは ALTER TABLE invoices ADD COLUMN total REAL を1回だけ実行してください。invoices.db を消してから起動し直す方法もあります(消すとサンプルの顧客と品目は作り直しになります)。

  2. 課題2:同じ請求書番号を何枚でも保存できる

    「🆕 新規」で入る番号は「年月日-時分」です。同じ1分の間に2回押すと、同じ番号になります。保存の SQL は INSERT OR REPLACE ですが、invoice_no に重複を防ぐ設定が無いため、同じ番号の行がそのまま増えます。

    期待結果:顧客を選んで「💾 保存」を2回押すと、どちらも 保存完了: INV-20260910-1530 と出てエラーにならない。invoices.dbSELECT invoice_no FROM invoices で覗くと、同じ番号の行が2つ入っている。直したあとは、2回目の保存で「この番号はすでにあります」と知らせて行を増やさない。

    合格条件

    • 同じ番号の請求書が2行できない(知らせて止めるか、番号を採番し直す)
    • 「🆕 新規」で入る番号は、今までどおり INV- で始まる
    • 保存できたときの 保存完了: ... の表示は変えない

    ヒント①(どこを触るか): 触るのは2か所です。番号を作る _new_invoice() の226行と、保存する _save_invoice() の288-291行。
    ヒント②(使うもの): '%Y%m%d-%H%M''%Y%m%d-%H%M%S' にすれば秒まで入り、同じ分でも別の番号になります。保存の前に SELECT 1 FROM invoices WHERE invoice_no = ? で調べます。見つかったら messagebox.showwarning(...) を出して return する形が最小です。
    つまずきやすい点: INSERT OR REPLACE が置き換えるのは、一意の制約にぶつかったときだけです。invoices で一意なのは id だけで(59-60行)、invoice_no には制約がありません。id を指定せずに INSERT すると毎回新しい id が振られるため、OR REPLACE は働きません。あとから UNIQUE を足すには表の作り直しが必要です(CREATE TABLE IF NOT EXISTS は既にある表を変更しません)。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。