1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

薬剤師がAIと挑む!ノーコードの壁を越える医薬品検索ツールの開発記

Last updated at Posted at 2025-08-23

1. 現場の「困った」が起点

私は神奈川県の薬局で働く薬剤師です。
日々の業務で最も時間を取られる課題の一つが、 医薬品の出荷状況の確認 でした。
なんと、医薬品安定供給にかかわる業務で、労働時間が1割以上奪われているのです。

image.png

医薬品の出荷状況を調べるのに、厚生労働省のウェブサイトから最新のExcelデータをダウンロードし、 17,000品目中3,000品目 にも及ぶ 出荷制限情報 を手動で確認するのは、非常に手間がかかる作業です。
たいていの人は、医薬品卸に何度も電話をかけて出荷状況を確認しています。
この課題を解決するため、まずはノーコードツール 「Make」「LINE Bot」 を組み合わせたプロトタイプ開発に挑戦しました。

2. 最初の壁:ノーコードでも乗り越えられない現実

MakeとLINE Botを使えば、専門知識がなくても手軽にツールを作成できると考えていました。
そして、満足できるレベルのものが完成しました。

しかし、すぐに二つの大きな壁に直面しました。

会社の許可。

LINE Botを業務で利用するには、社用端末でのLINE仕様のための正式な許可が必要だと判明しました。

費用の問題。

Makeの無料枠は1,000オペレーションで、検索回数が増えるとすぐに上限に達し、実用化にはコストがかかってしまうことが分かりました。

私は、外部ツールに依存せず、会社の許可も費用もかからない、 自前で完結するコンパクトなツール を作る必要に迫られました。

3. AIとの出会い:30分で生まれた「魔法」

途方に暮れていた時、AIがHTMLやJavaScriptといったウェブページのコードを生成できることを知りました。
プログラミングの知識は全くありませんでしたが、藁にもすがる思いでChatGPTに指示を出してみたのです。

すると、たった30分で、検索窓とボタンが付いた、 ちゃんと動作するウェブページのプロトタイプ が完成しました。
この瞬間、「これはいける!」と確信し、本格的な開発をAIと進めることを決意しました。

4. ChatGPTとの格闘と挫折

ChatGPTを相棒に、より高度な機能の実装に挑みました。

検索条件の追加。医薬品名だけでなく、成分名やメーカーでも検索できるようにしました。

データのアップロード機能。最新のExcelファイルを簡単にアップロードできるようにしました。

UI/UXの改善。ボタンの位置や色を変えるなど、見やすさを追求しました。

しかし、ここでまた壁にぶつかりました。
ChatGPTは複雑な指示に弱く、一つの問題を修正すると別の場所に不具合が出る 「モグラ叩き」 状態が続きました。

モグラ叩きの記録の一部

image.png

image.png

image.png

image.png

最終的に「半角入力でも検索できるようにする」という簡単な機能さえ解決できず、諦めかけた時、別のAIを試すというアイデアがひらめきました。

5. 救世主Gemini:AIを乗り換えるという選択

私は、不具合を抱えたコードをそのままGeminiに渡し、「このコードの不具合を修正してほしい」と依頼しました。
すると、信じられないことが起きました。

Geminiは、長大なコードを拒否することなく、不具合箇所を瞬時に特定し、修正したコードと丁寧な解説を返してくれたのです。
私には 解説の内容はほとんど意味不明 でしたが、これまでChatGPTで解決できなかった問題が、嘘のように解決しました。

このブレイクスルーによって、私は デバッグ作業から解放 され、余剰時間をより創造的な作業に充てることができました。

視覚的フィードバック の強化。データが正常にアップロードされたらボタンの色を緑に変えるなど、 直感的な操作性 を追求しました。

誰が見ても分かりやすいように、デザインを徹底的に作り込みました。
これは「売れるレベル」じゃないかと自画自賛しています。

6.完成したものがこちら

さらに、PCの表示は変えず、横幅の狭い画面(スマートフォン等)のために表示を(Geminiが)調整してみました。

私にはさっぱりわからないので、ここにコードを埋めておきます。
<!DOCTYPE ahtml>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>医薬品 出荷状況検索</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">
    <style>
        body {
            font-family: 'Inter', sans-serif;
        }
        /* Mobile-first approach for responsive table */
        @media (max-width: 767px) {
            #resultTable thead {
                display: none; /* Hide table headers on small screens */
            }
            #resultTable tbody tr {
                display: block; /* Make each table row a block element */
                border: 1px solid #e5e7eb;
                margin-bottom: 1rem;
                padding: 1rem;
                border-radius: 0.5rem;
            }
            #resultTable tbody td {
                display: flex; /* Display cells as flex items */
                justify-content: space-between;
                align-items: center;
                padding: 0.5rem 0;
                border-bottom: 1px solid #e5e7eb;
            }
            #resultTable tbody td:last-child {
                border-bottom: none;
            }
            #resultTable tbody td::before {
                content: attr(data-label); /* Use data-label attribute for pseudo-element content */
                font-weight: 600;
                text-transform: uppercase;
                color: #6b7280;
                font-size: 0.75rem;
                flex: 1;
                margin-right: 1rem;
            }
        }
    </style>
</head>
<body class="bg-gray-100 p-4 sm:p-8 flex flex-col items-center min-h-screen">
    <div class="bg-white p-6 sm:p-8 rounded-xl shadow-lg w-full max-w-4xl relative">
        <h2 class="text-2xl sm:text-3xl font-bold text-center text-gray-800 mb-6">医薬品 出荷状況検索</h2>
        
        <!-- メッセージ表示エリア -->
        <div id="messageBox" class="text-sm sm:text-base text-center p-3 rounded-lg mb-4 hidden absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-3/4"></div>

        <!-- ファイルアップロード/ダウンロード欄 -->
        <div class="mb-6">
            <label class="block text-gray-700 font-medium mb-2">Excelファイルを準備してください</label>
            <div class="flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4">
                <a href="https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/kouhatu-iyaku/04_00003.html" target="_blank" class="w-full sm:w-1/2">
                    <button class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow transition ease-in-out duration-150">厚生労働省のサイトでダウンロード</button>
                </a>
                <label for="fileInput" class="w-full sm:w-1/2 cursor-pointer block">
                    <span id="uploadButtonText" class="block bg-rose-500 hover:bg-rose-600 text-white font-bold py-2 px-4 rounded-md shadow text-center transition ease-in-out duration-150">
                        ファイルをアップロード
                    </span>
                    <input type="file" id="fileInput" accept=".xlsx, .xls" class="hidden">
                </label>
            </div>
        </div>
        
        <!-- 検索欄 -->
        <div class="space-y-4 mb-6">
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
                <div>
                    <label for="drugName" class="block text-gray-700 text-sm font-medium mb-1">医薬品名:</label>
                    <input type="text" id="drugName" placeholder="例: ロキソニン" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
                </div>
                <div>
                    <label for="ingredientName" class="block text-gray-700 text-sm font-medium mb-1">成分名:</label>
                    <input type="text" id="ingredientName" placeholder="例: ロキソプロフェン" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
                </div>
                <div>
                    <label for="makerName" class="block text-gray-700 text-sm font-medium mb-1">規格、剤形、メーカー:</label>
                    <input type="text" id="makerName" placeholder="例: 60mg" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
                </div>
            </div>
            <div class="flex items-end">
                <button onclick="searchData()" class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow transition ease-in-out duration-150">検索</button>
            </div>
        </div>

        <!-- 結果テーブル -->
        <div class="overflow-x-auto rounded-lg shadow-md border border-gray-200">
            <table id="resultTable" class="min-w-full divide-y divide-gray-200">
                <thead class="bg-gray-50">
                    <tr>
                        <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">品名</th>
                        <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">成分名</th>
                        <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">出荷対応状況</th>
                        <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">限定出荷/供給停止理由</th>
                        <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">出荷量状況</th>
                    </tr>
                </thead>
                <tbody class="bg-white divide-y divide-gray-200" id="resultTableBody">
                    <!-- 検索結果がここに追加されます -->
                </tbody>
            </table>
        </div>
    </div>

    <script>
        let excelData = [];
        const messageBox = document.getElementById('messageBox');
        
        // メッセージを表示する関数
        function showMessage(text, type = 'info') {
            messageBox.textContent = text;
            messageBox.classList.remove('hidden', 'bg-red-200', 'text-red-800', 'bg-green-200', 'text-green-800', 'bg-blue-200', 'text-blue-800');
            messageBox.classList.add('block');
            if (type === 'error') {
                messageBox.classList.add('bg-red-200', 'text-red-800');
            } else if (type === 'success') {
                messageBox.classList.add('bg-green-200', 'text-green-800');
            } else {
                messageBox.classList.add('bg-blue-200', 'text-blue-800');
            }
        }
        
        // 指定時間後にメッセージを非表示にする関数
        function hideMessage(delay) {
            setTimeout(() => {
                messageBox.classList.add('hidden');
            }, delay);
        }

        // カタカナをひらがなに変換する関数
        function katakanaToHiragana(str) {
            return str.replace(/[\u30a1-\u30f6]/g, function(match) {
                const chr = match.charCodeAt(0) - 0x60;
                return String.fromCharCode(chr);
            });
        }

        // 半角・全角、ひらがな・カタカナを区別せず、小文字に統一して検索する関数
        function normalizeString(str) {
            if (!str) return '';
            // カタカナをひらがなに変換
            let normalizedStr = katakanaToHiragana(String(str));
            // 小文字に変換
            normalizedStr = normalizedStr.toLowerCase();
            // 全角英数字を半角に変換
            return normalizedStr.replace(/[A-Za-z0-9]/g, function(s) {
                return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
            });
        }

        // ファイル読み込みイベントリスナー
        document.getElementById('fileInput').addEventListener('change', handleFile, false);
        
        function handleFile(event) {
            const file = event.target.files[0];
            const uploadButtonSpan = document.getElementById('uploadButtonText');
            if (!file) {
                showMessage("ファイルが選択されていません。", "error");
                hideMessage(2000); // 2秒後にメッセージを消す
                uploadButtonSpan.textContent = 'ファイルをアップロード';
                uploadButtonSpan.classList.remove('bg-green-500', 'hover:bg-green-600');
                uploadButtonSpan.classList.add('bg-rose-500', 'hover:bg-rose-600');
                return;
            }
            const reader = new FileReader();
            reader.onload = function(e) {
                const data = new Uint8Array(e.target.result);
                processExcelData(data);
                // ファイルが読み込まれたらテキストと色を変更
                uploadButtonSpan.textContent = 'ファイルをアップロード済み';
                uploadButtonSpan.classList.remove('bg-rose-500', 'hover:bg-rose-600');
                uploadButtonSpan.classList.add('bg-green-500', 'hover:bg-green-600');
            };
            reader.readAsArrayBuffer(file);
        }

        // Excelデータを処理する共通関数
        function processExcelData(data) {
            try {
                const workbook = XLSX.read(data, {type: 'array'});
                const sheetName = workbook.SheetNames[0];
                const sheet = workbook.Sheets[sheetName];
                excelData = XLSX.utils.sheet_to_json(sheet, {header:1});
                showMessage("Excelファイルを読み込みました。", "success");
                hideMessage(2000); // 2秒後にメッセージを消す
            } catch (error) {
                console.error('Error processing Excel file:', error);
                showMessage("Excelファイルの処理中にエラーが発生しました。", "error");
                hideMessage(2000); // 2秒後にメッセージを消す
            }
        }

        // 検索ボタンクリック時の処理
        function searchData() {
            if (excelData.length === 0) {
                showMessage("先にExcelファイルを読み込んでください。", "error");
                hideMessage(2000); // 2秒後にメッセージを消す
                return;
            }

            // 検索キーワードを正規化
            const drug = normalizeString(document.getElementById('drugName').value.trim());
            const ingredient = normalizeString(document.getElementById('ingredientName').value.trim());
            const maker = normalizeString(document.getElementById('makerName').value.trim());

            const resultBody = document.getElementById('resultTableBody');
            resultBody.innerHTML = "";

            let results = excelData.filter((row, index) => {
                if (index === 0) return false; // ヘッダー行を除外

                // データ側の文字列も正規化して比較
                const drugName = normalizeString(row[5]);      // F列: 品名
                const ingredientName = normalizeString(row[2]);  // C列: 成分名
                const makerName = normalizeString((row[5] || "") + " " + (row[6] || "")); // F+G列

                const matchDrug = drug === "" || drugName.includes(drug);
                const matchIngredient = ingredient === "" || ingredientName.includes(ingredient);
                const matchMaker = maker === "" || makerName.includes(maker);

                return matchDrug && matchIngredient && matchMaker;
            });

            if (results.length === 0) {
                const row = resultBody.insertRow();
                const cell = row.insertCell(0);
                cell.colSpan = 5;
                cell.textContent = "該当データがありません";
                cell.className = "px-4 py-3 text-sm text-gray-500 text-center italic";
                showMessage("検索結果が見つかりませんでした。", "info");
                hideMessage(2000); // 2秒後にメッセージを消す
                return;
            }

            results.forEach(row => {
                const newRow = resultBody.insertRow();
                newRow.className = "transition-colors duration-150 hover:bg-gray-50";
                
                // Set data-label attributes for mobile view
                newRow.insertCell(0).textContent = row[5] || "";
                newRow.cells[0].setAttribute('data-label', '品名');
                newRow.insertCell(1).textContent = row[2] || "";
                newRow.cells[1].setAttribute('data-label', '成分名');
                newRow.insertCell(2).textContent = row[11] || "";
                newRow.cells[2].setAttribute('data-label', '出荷対応状況');
                newRow.insertCell(3).textContent = row[13] || "";
                newRow.cells[3].setAttribute('data-label', '限定出荷/供給停止理由');
                newRow.insertCell(4).textContent = row[16] || "";
                newRow.cells[4].setAttribute('data-label', '出荷量状況');

                // Add classes for styling
                for (let i = 0; i < newRow.cells.length; i++) {
                    newRow.cells[i].className = "px-4 py-3 text-sm text-gray-900";
                }
            });
            showMessage(`${results.length} 件のデータが見つかりました。`, "success");
            hideMessage(2000); // 2秒後にメッセージを消す
        }
        
        // エンターキーで検索をトリガーするイベントリスナーを追加
        document.getElementById('drugName').addEventListener('keydown', (event) => {
            if (event.key === 'Enter') {
                searchData();
            }
        });
        document.getElementById('ingredientName').addEventListener('keydown', (event) => {
            if (event.key === 'Enter') {
                searchData();
            }
        });
        document.getElementById('makerName').addEventListener('keydown', (event) => {
            if (event.key === 'Enter') {
                searchData();
            }
        });
    </script>
</body>
</html>

7. 成果と学び:AIは「万能な相棒」

結果として、プログラミング知識ゼロの私が、たった数日で 「売れるレベル」 (と勝手に思っている)のツールを完成させることができました。

  • 検索 :医薬品名、成分名、規格・剤形・メーカーで部分一致検索が可能です。
  • 利便性:ひらがな・カタカナ、半角・全角の区別なく検索できます。
  • 視覚的:データアップロード状況を色で直感的に把握できます。
  • コスト:会社の許可も、継続的な費用も不要な内製ツールが完成しました。

今回の研修を通して、私は 「AIは万能な相棒」 だと強く実感しました。
AIはコーディングという作業を代行するだけでなく、専門知識がなくても実現できる課題の可能性を広げてくれます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?