5
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】薬局業務改善#1

Last updated at Posted at 2025-10-30

はじめに

こんにちは。
niwanoです。

薬局で働く薬剤師です。
2025年6月中旬にPythonと出会いました。今はスクールで勉強中です。
昔から物作り?LEGOが好きでした。
コードで自分が期待するアウトプットできている様子、その作成過程に楽しさを感じています。
というわけで、私にとっては珍しく学習が続いています。(続く理由はまた別記事で書きたいと思います)

業務的にどうしても書類(PDF)を印刷→FAXを送信しなければならないタスクが特定の患者さんの数だけ必要です。
今回その作業をPDF→宛先読み取り→宛名からメールアドレス読み込み、ない場合はTkinterによるUIでメールアドレスを入力して登録→PDF添付→送信リストのCSV出力の出力をコードにしてみました。

この記事を書いた理由

Tkinterのエラーについて、フリーズする現象の改善方法について共有したかったからです。
具体的にはTkinterはスレッドセーフでない、という前提がないが為に苦戦してしまいました。

エンジニアの皆さんであれば、常識かも知れませんが、これからPythonを勉強する方が同じような困りごとに直面した時の参考になればと思います。

“Tkinter is not thread-safe. Calls to Tkinter functions should only be made from the main thread.”
(Tkinterはスレッドセーフではありません。Tkinter関数はメインスレッドからのみ呼び出すべきです。)
-Python公式ドキュメント(tkinter — Python interface to Tcl/Tk)

まずコードが動くとどうなるのか

送付するpdfの例を添付しておきます

スクリーンショット 2025-10-30 21.05.47.png

様々な形式があると思いますが、一旦この形式を採用します。

こちらをご覧ください

図にすると以下です。

コードはこちら。

import shutil
import csv
import os
import glob
import json
import unicodedata
import smtplib
import re
import threading
import time
from pathlib import Path
from email.mime.text import MIMEText
from email.header import Header
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from pdfminer.high_level import extract_text
from pdfminer.layout import LAParams
import tkinter as tk
from tkinter import simpledialog
from tkinter import messagebox
from datetime import datetime
from dotenv import load_dotenv

# =========================================
# .envファイルから設定情報を読み込む
# =========================================
load_dotenv()
SENDER = os.getenv("SENDER")
PASSWORD = os.getenv("SMTP_PASSWORD")
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT"))
report_send = os.getenv("report_send")
report_sent = os.getenv("report_sent")
DB_JSON = Path(os.getenv("DB_JSON"))

EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
reports = glob.glob(os.path.join(report_send, "*.pdf"))

# DBファイルが存在しない場合は空のJSONを作成
if not DB_JSON.exists():
    DB_JSON.write_text("{}", encoding="utf-8")

with DB_JSON.open("r", encoding="utf-8") as f:
    db = json.load(f)


# =========================================
# PDFの特定ページをテキスト抽出する
# =========================================
def extract_page_text(pdf_path, pages):
    # pagesは [0](1ページ目)などのリスト形式
    return extract_text(pdf_path, page_numbers=pages, laparams=LAParams())


# =========================================
# 医療機関ごとのメールアドレスをJSONに登録・更新する
# =========================================
def upsert_email_for_institution(name: str, email: str) -> None:
    key = _norm(name)
    with DB_JSON.open("r", encoding="utf-8") as f:
        db = json.load(f)
    db[key] = email
    with DB_JSON.open("w", encoding="utf-8") as f:
        json.dump(db, f, ensure_ascii=False, indent=2)


# =========================================
# 未登録の医療機関に対してメールアドレスを入力してもらうUIを表示
# =========================================
def prompt_and_register_email(clinic_name: str):
    prev = ""
    while True:
        addr = simpledialog.askstring(
            title="宛先メール登録",
            prompt=f"未登録の医療機関です:\n{clinic_name}」のメールアドレスを入力(例:name@example.jp)",
            initialvalue=prev,
            parent=ROOT,
        )
        if addr is None:
            return None
        addr = _norm(addr)
        if EMAIL_RE.match(addr):
            ok = messagebox.askyesno(
                "確認",
                f"以下の内容で登録します。\n\n医療機関:{clinic_name}\nメール:{addr}\n\nよろしいですか?",
                parent=ROOT,
            )
            if ok:
                upsert_email_for_institution(clinic_name, addr)
                messagebox.showinfo(
                    "登録完了", f"{clinic_name}\n{addr} を登録しました。", parent=ROOT
                )
                return addr
            else:
                prev = addr
                continue
        else:
            messagebox.showwarning(
                "形式エラー",
                "メール形式が不正です。\n例:name@example.jp",
                parent=ROOT,
            )
            prev = addr
            continue


# =========================================
# 現在のスレッドがメインスレッドかどうかを判定
# =========================================
def _on_main_thread() -> bool:
    return threading.current_thread() is threading.main_thread()


# =========================================
# 別スレッドからでも安全にTkダイアログを呼び出す関数
# =========================================
def ask_mail_on_main(clinic_name: str):
    if not ROOT.winfo_exists():
        print("Tkが初期化/破棄済みのためスキップ:", clinic_name)
        return None
    if _on_main_thread():
        return prompt_and_register_email(clinic_name)

    box, ev = {}, threading.Event()

    def _ask():
        box["v"] = prompt_and_register_email(clinic_name)
        ev.set()

    try:
        ROOT.after(0, _ask)
    except Exception:
        return None
    if not ev.wait(120):
        print("UI応答なしでタイムアウト:", clinic_name)
        return None
    return box.get("v")


# =========================================
# 全角→半角、前後空白削除など文字正規化を行う
# =========================================
def _norm(s: str):
    return unicodedata.normalize("NFKC", s or "").strip()


# =========================================
# 医療機関名から登録済みメールアドレスを取得する
# =========================================
def get_email_for_institution(name: str):
    with DB_JSON.open("r", encoding="utf-8") as f:
        db = json.load(f)
    key = _norm(name)
    if key in db and db[key]:
        return db[key]
    raise KeyError(f"未登録の医療機関:{name}")


# =========================================
# Tkウィンドウがまだ存在するかをチェック
# =========================================
def _tk_alive() -> bool:
    try:
        return ROOT.winfo_exists()
    except tk.TclError:
        return False


# =========================================
# Tkウィンドウを安全に閉じる
# =========================================
def close_tk_safe():
    if _tk_alive():
        try:
            ROOT.after(0, ROOT.destroy)  # メインスレッド経由で安全に閉じる
        except tk.TclError:
            pass


# =========================================
# メイン処理:PDF読み込み→宛先判定→メール送信
# =========================================
def main():
    print("=== main start ===")
    with smtplib.SMTP_SSL("sauth.alpha-mail.jp", 465) as smtp:
        smtp.login(SENDER, PASSWORD)

        for report in reports:
            # --- PDFから情報抽出 ---
            text_p1 = extract_page_text(report, [0])  # 1ページ目のみ
            lines = [line for line in text_p1.splitlines() if line.strip() != ""]
            clinic_name = _norm(lines[3]) if len(lines) > 3 else ""
            receiver = db.get(clinic_name)

            # --- 宛先未登録ならUIで入力 ---
            if not receiver:
                entered = ask_mail_on_main(clinic_name)
                if not entered:
                    print(f"{clinic_name} をスキップ")
                    continue
                receiver = entered
                upsert_email_for_institution(clinic_name, receiver)
                db[clinic_name] = receiver

            # --- PDFテキストから必要情報を抽出 ---
            Dr = re.search(r".+先生", text_p1)
            Pt = re.search(r"患者氏名[ ]*\s*(.+)", text_p1)
            pharmacy = re.search(r".+薬局\s*(.+)", text_p1)
            pharmacist = re.search(r"訪問薬剤師名\s*(.+)", text_p1)
            Dr_name = Dr.group().replace("\u3000", "") if Dr else ""
            pt_name = Pt.group(1) if Pt else ""
            pharmacy_name = pharmacy.group().replace("\u3000", "") if pharmacy else ""
            pharmacist_name = (
                pharmacist.group(1).replace("\u3000", "").strip() if pharmacist else ""
            )

            # --- メール本文の作成 ---
            message = MIMEMultipart()
            message["Subject"] = Header(
                f"{pharmacy_name}/{pharmacist_name}{pt_name}の在宅報告書を送付致します",
                "utf-8",
            )
            message["From"] = SENDER
            message["To"] = receiver

            body = f"""
{clinic_name}
{Dr_name}

お世話になっております。
{pharmacy_name}の薬剤師{pharmacist_name}です。

在宅報告書を送付致します。
ご確認いただけますと幸いです。
引き続き何卒よろしくお願い致します。

{'---'*max(len(f'薬剤師 {pharmacist_name}'), len(pharmacy_name))}
{pharmacy_name}
薬剤師 {pharmacist_name}
{'---'*max(len(f'薬剤師 {pharmacist_name}'), len(pharmacy_name))}
            """
            message.attach(MIMEText(body, _subtype="plain", _charset="utf-8"))

            # --- PDF添付 ---
            with open(report, "rb") as f:
                part = MIMEApplication(f.read(), _subtype="pdf")
            filename = os.path.basename(report)
            part.add_header(
                "Content-Disposition", "attachment", filename=("utf-8", "", filename)
            )
            message.attach(part)

            # --- メール送信 ---
            smtp.send_message(message)

            # --- ログCSVへ追記 ---
            LOG_CSV = Path("send_log.csv")
            with LOG_CSV.open("a", newline="", encoding="utf-8") as f:
                writer = csv.writer(f)
                if f.tell() == 0:
                    writer.writerow(["送信日時", "医療機関名", "宛先メール", "患者氏名", "報告書ファイル名"])
                writer.writerow(
                    [
                        datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                        clinic_name,
                        receiver,
                        pt_name,
                        os.path.basename(report),
                    ]
                )

            # --- 送信済みフォルダへ移動 ---
            Path(report_sent).mkdir(parents=True, exist_ok=True)
            shutil.move(report, os.path.join(report_sent, os.path.basename(report)))

            time.sleep(2)

    print("=== main end ===")
    ROOT.after(0, ROOT.quit)


# =========================================
# Tkウィンドウ初期化とメインスレッド起動
# =========================================
ROOT = tk.Tk()
ROOT.withdraw()

if __name__ == "__main__":
    threading.Thread(target=main, daemon=True).start()
    ROOT.mainloop()
    if ROOT.winfo_exists():
        ROOT.destroy()

苦戦したところ=tkinterが終了しない

コードのが動き終わっているはずでも、tkinterが終了せず、フリーズ状態になる現象がありました。(tkが閉じない状態)
完全に理解が及んでいないのですが、メインスレッドでしかtkinterは動作しないので、メインスレッドで実行しているか観測する関数を入れ込んでいます。

今回この問題を調べていて、ようやく腑に落ちました。
結論としてはTkinterはスレッドセーフではありません。

つまり、複数のスレッドから同時にTkinterを操作すると壊れてしまうということです。
UIの更新は必ず「メインスレッド(main)」で行わなければなりません。

私のコードでは、
重い処理(PDF読み込みやメール送信)は別スレッドで実行し、
UI操作(ダイアログ表示など)はメインスレッドに戻すようにしています。

def ask_mail_on_main(clinic_name: str):
    if not ROOT.winfo_exists():
        print("Tkが初期化/破棄済みのためスキップ:", clinic_name)
        return None
    if _on_main_thread():
        return prompt_and_register_email(clinic_name)

    box, ev = {}, threading.Event()

    def _ask():
        box["v"] = prompt_and_register_email(clinic_name)
        ev.set()

    ROOT.after(0, _ask)   # ← メインスレッドでUIを呼び出す
    ev.wait(120)
    return box.get("v")

ROOT.after(0, _ask) を使うことで、
「今は別スレッドで動いているけど、UI部分だけはメインスレッドに戻して安全に実行する」
という仕組みになっています。

学びのまとめとしては
・Tkinterはスレッドセーフではない(=同時に触ると壊れる)
・UI操作はメインスレッドだけで行う
・重い処理は別スレッドに逃がす
両者の橋渡しに after() や queue を使うと安全
最初は「Tkinterが終了しない!」というバグにしか見えなかったけれど、
実は'スレッドセーフ”という設計思想の壁にぶつかっていた'と理解しました。
この気づきにかなり時間を要してしましたので、ぜひこの経験が他のPython初心者に気づきを与えられると嬉しいです。

これから

業務効率化=薬剤師の時給アップを目指して、小規模の薬局の業務改善をサポートしていけるようにスキルを磨いていきます!

薬局業務×Pythonとニッチな領域ですが、フォローしていただけますと幸いです。

私は全ての薬局が利用できる'大きな'サービスよりも、'個々の薬局毎に最適化された細かい'サービスを薬局側が自由に選択できるサービスが良いと感じております。
(理由はまた今度!)

あるサービスの営業を受けたときに、このサービスはいいけど、このサービスは不要。
全体コストとしての割高感から、サービスの契約につながらないことがあります。

そんな業務アプリケーションの種を現地に赴いて細かく調べて、形にできれば、それだけで現場の効率化→患者さんの待ち時間軽減→対人業務として、患者さんと話す時間が増える→思わぬ本音を聞ける→薬物治療への貢献につながると信じて、初めて投稿するQiitaを終えようと思います!

よかったらまた見にきてください!

今回のまとめが以下です。

5
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
5
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?