# PythonLand 投資×Python シリーズ 第8回
# 「Pythonで配当金管理ツールを作る（tkinter × SQLite）」
# https://pythonland.tech/dividend-management-tkinter.html
#
# 記事2〜8章のコードをつないだ完成版です（2026-09-01 時点）。
# 標準ライブラリだけで動きます（追加インストール不要・完全にローカルで動作します）。
# 保存先はこのファイルと同じフォルダの dividends.db 1ファイルだけで、外部との通信はありません。
#
# ⚠️ SEED_STOCKS の銘柄コードに推奨の意味はありません。自分の保有銘柄に書き換えてください。
# ⚠️ 本ツールは税額を一切計算しません。net_sen（入金額）は実際に振り込まれた金額を手で入力する列です。
#    配当の課税関係は口座の種類・受取方法・その年の制度で変わります。詳細は国税庁の情報か税理士へ。
# ライセンス: MIT（https://pythonland.tech/ のコードは MIT ライセンスで公開しています）

"""配当の入金記録をSQLiteに貯め、銘柄別・月別に集計するtkinterアプリ。"""
import sqlite3
import tkinter as tk
from contextlib import closing
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path
from tkinter import messagebox, ttk

DB_PATH = Path(__file__).with_name("dividends.db")

SCHEMA = """
CREATE TABLE IF NOT EXISTS stocks (
    ticker    TEXT PRIMARY KEY,          -- '7203.T' のような識別子
    name      TEXT NOT NULL,
    name_kana TEXT NOT NULL              -- 読み（並べ替え用・7章で効く）
);
CREATE TABLE IF NOT EXISTS dividends (
    id            INTEGER PRIMARY KEY,
    paid_on       TEXT    NOT NULL,      -- 入金日 'YYYY-MM-DD' 固定
    ticker        TEXT    NOT NULL REFERENCES stocks(ticker),
    shares        INTEGER NOT NULL,
    per_share_sen INTEGER NOT NULL,      -- 1株あたり配当（銭＝円×100）
    net_sen       INTEGER NOT NULL,      -- 実際の入金額（銭）
    created_at    TEXT    NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_div_paid_on ON dividends(paid_on);
"""

# 初回起動時だけ入る銘柄マスタ。推奨銘柄ではありません（自分の保有銘柄に置き換えてください）
SEED_STOCKS = [
    ("7203.T", "トヨタ自動車", "トヨタジドウシャ"),
    ("8306.T", "三菱UFJフィナンシャル・グループ", "ミツビシユーエフジェイフィナンシャルグループ"),
    ("9432.T", "日本電信電話", "ニッポンデンシンデンワ"),
    ("8411.T", "みずほフィナンシャルグループ", "ミズホフィナンシャルグループ"),
    ("4568.T", "第一三共", "ダイイチサンキョウ"),
]

SQL_DETAIL = """
SELECT d.id, d.paid_on, s.name, d.shares, d.per_share_sen,
       d.shares * d.per_share_sen AS gross_sen, d.net_sen
  FROM dividends d JOIN stocks s ON s.ticker = d.ticker
 ORDER BY d.paid_on DESC, d.id DESC
"""
SQL_BY_TICKER = """
SELECT s.name, COUNT(*) AS n,
       SUM(d.shares * d.per_share_sen) AS gross_sen, SUM(d.net_sen) AS net_sen
  FROM dividends d JOIN stocks s ON s.ticker = d.ticker
 GROUP BY d.ticker ORDER BY net_sen DESC
"""
SQL_BY_MONTH = """
SELECT strftime('%Y-%m', paid_on) AS ym, COUNT(*) AS n,
       SUM(shares * per_share_sen) AS gross_sen, SUM(net_sen) AS net_sen
  FROM dividends GROUP BY ym ORDER BY ym
"""
SQL_YEAR_TOTAL = """
SELECT COALESCE(SUM(net_sen), 0), COUNT(*)
  FROM dividends WHERE strftime('%Y', paid_on) = ?
"""


# ---------------------------------------------------------------- DB層（5章）
def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row          # 列名でアクセスできるようにする
    conn.execute("PRAGMA foreign_keys = ON")
    return conn


def query(sql, params=()):
    """読み取り専用。closing() で必ず接続を閉じる。"""
    with closing(connect()) as conn:
        return conn.execute(sql, params).fetchall()


def execute(sql, params=()):
    """書き込み。closing()=閉じる／with conn=コミット（例外ならロールバック）。"""
    with closing(connect()) as conn, conn:
        return conn.execute(sql, params).lastrowid


def init_db():
    """テーブルを作り、stocks が空のときだけ初期値を入れる（何回呼んでも安全）。"""
    with closing(connect()) as conn, conn:
        conn.executescript(SCHEMA)
        if conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] == 0:
            conn.executemany("INSERT INTO stocks VALUES (?, ?, ?)", SEED_STOCKS)


def add_dividend(paid_on, ticker, shares, per_share_sen, net_sen):
    """配当の明細を1件登録する。"""
    return execute(
        "INSERT INTO dividends (paid_on, ticker, shares, per_share_sen, net_sen)"
        " VALUES (?, ?, ?, ?, ?)",
        (paid_on, ticker, shares, per_share_sen, net_sen))


# ------------------------------------------------------------ 金額の変換（4章）
def yen_to_sen(text):
    """円の文字列 -> 銭(int)。1銭未満は ValueError。"""
    try:
        sen = Decimal(str(text).strip().replace(",", "")).scaleb(2)
    except InvalidOperation:
        raise ValueError(f"金額として読めません: {text!r}")
    if sen != sen.to_integral_value():
        raise ValueError(f"1銭未満は扱えません: {text!r}")
    return int(sen)


def sen_to_yen(sen):
    """銭(int) -> 表示用の円文字列。956200 -> '9,562'。"""
    return f"{Decimal(sen) / 100:,}"


# ------------------------------------------------------------ Treeview（7章）
def as_text(s):
    return s


def as_number(s):
    return float(s.replace(",", ""))


def sort_column(tree, col, descending, key):
    """見出しクリックで並べ替える（自作。move() で行の位置を入れ替えるだけ）。"""
    rows = sorted(((key(tree.set(iid, col)), iid) for iid in tree.get_children("")),
                  reverse=descending)
    for index, (_, iid) in enumerate(rows):
        tree.move(iid, "", index)
    tree.heading(col, command=lambda: sort_column(tree, col, not descending, key))


def make_tree(parent, columns):
    """(列ID, 見出し, 幅, 寄せ, 並べ替えキー) のリストから Treeview を組み立てる。"""
    frame = ttk.Frame(parent)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(0, weight=1)
    tree = ttk.Treeview(frame, columns=[c[0] for c in columns],
                        show="headings", selectmode="browse", height=10)
    for cid, text, width, anchor, key in columns:
        tree.heading(cid, text=text,
                     command=lambda c=cid, k=key: sort_column(tree, c, False, k))
        tree.column(cid, width=width, anchor=anchor, stretch=(cid == "name"))
    bar = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
    tree.configure(yscrollcommand=bar.set)
    tree.tag_configure("thisyear", background="#eef6ff")
    tree.grid(row=0, column=0, sticky="nsew")
    bar.grid(row=0, column=1, sticky="ns")
    frame.pack(fill="both", expand=True, padx=6, pady=6)
    return tree


DETAIL_COLUMNS = [
    ("paid_on", "入金日", 100, "center", as_text),
    ("name", "銘柄", 300, "w", as_text),
    ("shares", "株数", 80, "e", as_number),
    ("per_share", "1株配当", 90, "e", as_number),
    ("gross", "税引前", 110, "e", as_number),
    ("net", "入金額", 110, "e", as_number),
]
BY_TICKER_COLUMNS = [
    ("name", "銘柄", 300, "w", as_text),
    ("n", "件数", 80, "e", as_number),
    ("gross", "税引前 合計", 130, "e", as_number),
    ("net", "入金額 合計", 130, "e", as_number),
]
BY_MONTH_COLUMNS = [
    ("ym", "年月", 100, "center", as_text),
    ("n", "件数", 80, "e", as_number),
    ("gross", "税引前 合計", 120, "e", as_number),
    ("net", "入金額 合計", 130, "e", as_number),
]


# ------------------------------------------------------------------ 画面（6章）
class DividendApp(ttk.Frame):
    # (属性名, ラベル, 行, 列, 入力チェック)
    FIELDS = [
        ("v_date", "入金日", 0, 0, None),
        ("v_shares", "株数", 1, 0, "digits"),
        ("v_per_share", "1株配当（円）", 1, 2, "amount"),
        ("v_net", "入金額（円）", 1, 4, "amount"),
    ]

    def __init__(self, master):
        super().__init__(master, padding=8)
        self.pack(fill="both", expand=True)
        self.label_to_ticker = {}
        self._build_form()
        self._build_tabs()
        self.reload()

    def _build_form(self):
        box = ttk.LabelFrame(self, text="配当を登録する", padding=10)
        checks = {"digits": (self.register(lambda p: p == "" or p.isdecimal()), "%P"),
                  "amount": (self.register(self._is_amount), "%P")}
        for attr, label, row, col, check in self.FIELDS:
            ttk.Label(box, text=label).grid(row=row, column=col, sticky="w", padx=(4, 6), pady=4)
            var = tk.StringVar()
            setattr(self, attr, var)
            entry = ttk.Entry(box, textvariable=var, width=14)
            if check:                                  # ← 1文字ごとの検証を有効にする
                entry.configure(validate="key", validatecommand=checks[check])
            entry.grid(row=row, column=col + 1, sticky="w", padx=4, pady=4)
        self.v_date.set(date.today().isoformat())

        ttk.Label(box, text="銘柄").grid(row=0, column=2, sticky="w", padx=(4, 6), pady=4)
        self.v_stock = tk.StringVar()                  # 銘柄は選択式（打ち込ませない）
        self.combo = ttk.Combobox(box, textvariable=self.v_stock, state="readonly",
                                  width=32, values=list(self.label_to_ticker))
        self.combo.grid(row=0, column=3, sticky="w", padx=4, pady=4)
        ttk.Button(box, text="登録する", command=self.on_submit).grid(
            row=2, column=5, sticky="e", padx=4, pady=(8, 2))
        box.pack(fill="x")

    @staticmethod
    def _is_amount(p):        # 小数点は1つまで
        return p in ("", ".") or p.count(".") <= 1 and p.replace(".", "").isdecimal()

    def _build_tabs(self):
        nb = ttk.Notebook(self)
        pages = [("明細", DETAIL_COLUMNS), ("銘柄別", BY_TICKER_COLUMNS), ("月別", BY_MONTH_COLUMNS)]
        self.trees = {}
        for title, columns in pages:
            page = ttk.Frame(nb)
            self.trees[title] = make_tree(page, columns)
            nb.add(page, text=title)
        nb.pack(fill="both", expand=True, pady=(8, 4))
        self.v_total = tk.StringVar()
        ttk.Label(self, textvariable=self.v_total, anchor="e").pack(fill="x")

    # ---------------------------------------------------------- 登録（6章）
    def on_submit(self):
        try:
            paid_on = date.fromisoformat(self.v_date.get().strip()).isoformat()
        except ValueError:
            messagebox.showerror("入力エラー", "入金日は 2026-06-26 の形式で入力してください。")
            return
        ticker = self.label_to_ticker.get(self.v_stock.get())
        if ticker is None:
            messagebox.showerror("入力エラー", "銘柄を選んでください。")
            return
        try:
            shares = int(self.v_shares.get())
            per_share_sen = yen_to_sen(self.v_per_share.get())
            net_sen = yen_to_sen(self.v_net.get())
        except ValueError as e:
            messagebox.showerror("入力エラー", str(e))
            return
        add_dividend(paid_on, ticker, shares, per_share_sen, net_sen)
        self.reload()

    # ---------------------------------------------------------- 再読み込み
    def reload(self):
        self.label_to_ticker = {f"{r['name']}（{r['ticker']}）": r["ticker"]
                                for r in query("SELECT ticker, name FROM stocks"
                                               " ORDER BY name_kana")}
        self.combo["values"] = list(self.label_to_ticker)

        this_year = date.today().strftime("%Y")
        detail = self.trees["明細"]
        detail.delete(*detail.get_children(""))
        for r in query(SQL_DETAIL):
            tags = ("thisyear",) if r["paid_on"][:4] == this_year else ()
            detail.insert("", "end", iid=str(r["id"]), tags=tags, values=(
                r["paid_on"], r["name"], f"{r['shares']:,}",
                sen_to_yen(r["per_share_sen"]), sen_to_yen(r["gross_sen"]),
                sen_to_yen(r["net_sen"])))

        by_ticker = self.trees["銘柄別"]
        by_ticker.delete(*by_ticker.get_children(""))
        for r in query(SQL_BY_TICKER):
            by_ticker.insert("", "end", values=(
                r["name"], r["n"], sen_to_yen(r["gross_sen"]), sen_to_yen(r["net_sen"])))

        by_month = self.trees["月別"]
        by_month.delete(*by_month.get_children(""))
        for r in query(SQL_BY_MONTH):
            by_month.insert("", "end", values=(
                r["ym"], r["n"], sen_to_yen(r["gross_sen"]), sen_to_yen(r["net_sen"])))

        total_sen, n = query(SQL_YEAR_TOTAL, (this_year,))[0]
        self.v_total.set(f"{this_year}年の入金額 合計: {sen_to_yen(total_sen)} 円（{n}件）  ")


def main():
    init_db()
    root = tk.Tk()
    root.title("配当金管理ツール")
    root.geometry("960x470")
    DividendApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
