研修医がDr.コトー診療所の在庫管理アプリを作った話

医療現場において低コストでのDX化に挑戦しました。
ご協力頂いた皆様に改めて感謝します。

<div class="minit1">
<table> 
<tr>
<td>診療所内のポスター</td>
</tr>
<tr>
<td>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/d626934b-bce2-65dc-b1a3-8f40ad241241.jpeg">
</td>
</tr>
<tr>
<td>頂いた反響</td>
</tr>
<tr>
<td>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/d2d406a3-68d0-1502-e4bf-581ae6e20eb1.png">
</td>
</tr>
</table>

## はじめに
医療現場では、患者の安全と迅速な治療を支えるため、医療材料・衛生材料の在庫管理が重要です。しかし、小規模な診療所では、依然として手作業での管理が多く、効率化が求められています。
研修先であった下甑手打診療所（Dr.コトー診療所のモデル）にて研修期間中に依頼を受け、医療材料の在庫管理アプリを開発しました。私は20年前に下甑島に住んでおり、当時の院長であった瀬戸上先生（Dr.コトーのモデル）には大変お世話になりました。

## 現状と課題
### 現状
- **施設概要**: 病院は2階建てで、1階には窓口、外来、医療資材室があり、2階には病室と透析室があります。
- **在庫管理の方法**: 医療材料は約370種類。以前は紙の台帳で管理していましたが、記入漏れが増加し廃止となりました。現在は在庫の下に赤いカードを設置し、カードが見えたら注文する仕組みを採用しています。
- **集計作業**: 月末に手入力で在庫一覧を集計する作業が必要で、在庫が消費される場所がバラバラなため、正確な在庫の把握が大変です。

<table>
<tr>
<td>資材室の外観</td>
<td>資材室の内観</td>
</tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/2b040c90-f802-28cb-2f87-295e32e7d6c2.jpeg"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/2e4d0e90-e0c4-8e56-e9e6-0c96957a4f96.jpeg"></td>
</tr>
<tr>
<td>在庫切れが近い事を示す赤いカード</td>
<td>月末に手入力した在庫一覧</td>
</tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/503cfd5b-b484-0cc8-20c8-d0021292b12f.jpeg"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/ea13fd23-d39a-d585-aa20-7623cc9936c8.jpeg"></td>
</tr>
</table>

### 課題
- **月末の集計レポート作成が大変**: 電子カルテと紙カルテが混在しているため、使用数の正確な把握が難しく、帳尻合わせに時間がかかります。
- **在庫把握が困難**: リアルタイムでの在庫把握ができず、在庫を探し出す手間が発生しています。

## 依頼内容
上記の課題を解決するため、以下の要件を満たす在庫管理アプリの開発を依頼されました。

- **現在の在庫の把握**: QRコードでの入出庫管理を導入。すでに各在庫に貼り付けられたカードにQRコードを追加し、入力ミスを防ぐことが依頼主の強い希望でした。
- **使いやすさ重視**: デジタル端末に不慣れな方でも操作できる設計を重視。
- **月末レポート出力**: 指定した月の在庫変動をレポートとして出力できる機能を実装。

## 開発要件と選定理由
皆さんスマートフォンをお持ちだったので、各人の端末からWebアプリを利用して、在庫の情報を閲覧・入力して頂く方針としました。
### 自身に課した開発要件
- **データベースの保全性**: Excel操作に慣れている方が簡単に扱えるようにする。
- **低コスト運用**: 小規模開発とし、維持費を抑える。
- **QRコードと手動入力**: QRコードの設置コストを抑えつつ、手動入力もサポート。
- **在庫一覧の実装**: 検索機能や並び替え機能を追加し、使いやすさを向上。
- **履歴管理**: 過去の記録を削除でき、入力ミスに対応可能とする。
- **月別レポート作成**: 指定月の入出庫数を自動集計し、レポート形式で出力。

### 選定とその理由
- **GoogleスプレッドシートとGoogle Apps Script(GAS)**:
  - **操作の直感性**: ユーザーが直感的に操作でき、Excelに慣れた方にとって親しみやすい。
  - **データの保全**: 印刷が容易で、台風や停電時には紙での管理にも移行可能。
  - **柔軟なレポート作成**: スプレッドシート上で編集でき、Excel形式での保存・印刷が可能。

## 完成したプロダクト
### Webアプリ
- **入力方法**: QRコードと手動入力の2種類を提供。QRコードを読み取ることで在庫数を簡単に増減できます。
- **画面更新機能**: 30秒ごとにスプレッドシートから最新情報を取得し、在庫一覧・履歴一覧を更新します。ブラウザのタブを切り替えたときにも更新され、閲覧されていない間は更新を停止。手動更新も可能です。
- **在庫閲覧**: 商品名や商品番号での検索が可能で、最新の在庫状況が一目で確認できます。
- **修正機能**: 履歴の削除ができ、入力ミスにも柔軟に対応可能です。

<table>
<td>ホーム画面</td>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/0e6774e3-b298-29c8-1307-8e0a8b4d9bad.png"></td>
</tr>
<tr>
<td>手入力</td>
<td>手入力：商品選択後</td>
</tr>
</tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/98c8630c-3a10-a5f5-3455-7d2112688ce8.png"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/9544677c-5f95-1bb9-3cbb-18132244610f.png"></td>
</tr>
<tr>
<td>カメラ入力</td>
<td>カメラ入力：QRコード読み取り後</td>
</tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/eccaa278-8542-953b-4085-890b8d7212fe.png"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/b5e5d2ea-dd13-c80b-8eb2-4ddffff62811.png"></td>
</tr>
<tr>
<td>履歴一覧</td>
<td>履歴一覧：削除時</td>
</tr>

<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/d67922fa-084b-40f9-c5fc-b5d53c24bc51.png"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/ea9a2dfc-42f9-b201-028e-aca323ce666e.png"></td>
</tr>
</table>


### スプレッドシート
- **在庫管理タブ**: すべてのデータを入力するメインのシートです。
 <table>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/7a23a5d1-3cde-b698-e1b7-9ca1f05866df.png"></td>
</tr>
<td>在庫一覧タブ</td>
</table> 

- **操作ボタンタブ**: 利便性を高めるため、以下のボタンを設置しました。
  - **QRコード生成ボタン**: 新規商品追加時等に実行し全商品のQRコードを生成します。生成されたQRコードを印刷し、前述の赤いカードに貼り付けて運用する事を想定しています。
  - **月別レポート生成ボタン**: 指定月のレポートを簡単に生成します。
  - **再計算ボタン**: 在庫管理タブのデータ修正時に在庫数を再計算します。


<div class="minit1">
<table> 
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/0a2043ee-2b16-08ff-e744-a923dcb909fa.png"></td>
</tr>
<td>操作ボタンタブ</td>
</tr>
</table> 
</div>

- **QRコードタブ**: QRコードの生成場所。印刷して前述の赤いカードに貼り付け、在庫管理に活用します。

<table> 
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/299a6004-cb61-60cd-1a2a-0b8488227f89.png"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/cca63c53-28af-230a-3ebf-8e41423c1157.jpeg"></td>
</tr>
<td>QRコードタブ</td>
<td>QRコード運用イメージ</td>
</tr>
</table> 

- **月別レポートタブ**: 前述のボタン操作時に、指定した月の在庫変動を集計したレポートを生成します。

<div class="minit1">
<table> 
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2657635/de79069c-5be7-601f-dcd5-0899ac667b8d.png"></td>
</tr>
<td>月別レポート</td>
</tr>
</table> 
</div>

<style>
.minit1 table {
    width: 40%;
margin-right:auto
}
</style>

## 開発に関して
### 開発期間
開発担当は自分ひとりで約１ヶ月半ほど。週末を中心に開発を進めました。

### Webアプリ
構想を考える際には、こちらのサイトが参考になりました。

- [【詳解】GAS(Google Apps Script)で最も単純なWebページを公開しよう](https://wywy.jp/blogs/gas-web-detailed-explanation)


Google Apps Script内にコードファイル（.gs）とHTMLファイル（.html）を配置し、スプレッドシートとのやり取りを行う関数をコードファイルに記載。HTMLファイルとデータをやり取りする形としました。

## Tips
### 画面サイズの調整について
基本的にスマホで使用する事を想定してるため、画面サイズを対応させる必要がありました。

参考記事：[【2021.10.20】GASのWebアプリをスマホでいい感じに表示する方法！](https://note.com/221krn/n/n441b27e6ae47)

### QRコード生成
依頼主の希望に基づき、追加用と減少用のQRコードを別々に作成する方針にしました。
QRコードの生成とセルの大きさの指定、時間制限対策のトリガー設定、QRコード生成の進捗状況の管理を行いました。
- QRコード生成については、[QR Code GeneratorのQR Code API](https://goqr.me/de/api/)を利用しています。

暗号化せずシンプルに、商品番号に「_plus1」と「_minus1」を付けたデータをQRコード化しています。

- 6分の壁を超えるためのトリガー管理に関しては、以下のサイトを参考にしました。

[GASでタイムアウトエラーを回避する方法【6分の壁/30分の壁】](https://uncle-gas.com/avoid-timeout-error/)

[Script Serviceによるスクリプトの自動制御 (2/4)■Triggerオブジェクトでトリガー情報を得る](https://www.tuyano.com/index3?id=935001&page=2)

今回使用したScriptApp.getScriptTriggers(); は非推奨になっており、現状サポートされている ScriptApp.getProjectTriggers(); を使う方が望ましいそうです。

[繰り返しアイテムの日付プロパティに未来の日時を設定する ～ イチ(以下)から学ぶNotionAPI×Google Apps Script【Day.6】](https://note.com/necoko168/n/n1c943ce1ac56)）

- 進捗状況の管理は、[ScriptProperties](https://developers.google.com/apps-script/guides/properties?hl=ja)を使用しています。


<details><summary>QRコード生成関数のコード</summary>

```
function generateAllQRCodes() {
  const startTime = new Date(); // 実行開始時刻を記録
  const scriptProperties = PropertiesService.getScriptProperties();
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const stockSheet = spreadsheet.getSheetByName("在庫管理");
  const qrSheet = spreadsheet.getSheetByName("QRコード");

  // 途中から再開するためのインデックス
  let startIndex = Number(scriptProperties.getProperty("startIndex")) || 0;

  // 最初の実行であればQRコードタブの情報をクリア
  if (startIndex === 0) {
    qrSheet.clear();
    const images = qrSheet.getImages();
    images.forEach((image) => image.remove());
    qrSheet
      .getRange(1, 1, 1, 4)
      .setValues([["番号", "商品名", "QRコード（増加）", "QRコード（減少）"]]);
    qrSheet.setRowHeight(1, 150);
  }

  const lastRow = stockSheet.getLastRow();
  if (lastRow < 2) {
    SpreadsheetApp.getUi().alert("在庫管理シートに商品が存在しません。");
    return;
  }

  const productData = stockSheet.getRange(2, 1, lastRow - 1, 2).getValues();
  const cellSize = 180;

  for (let i = startIndex; i < productData.length; i++) {
    const currentTime = new Date();
    const elapsedSeconds = (currentTime - startTime) / 1000;
    if (elapsedSeconds > 300) {
      // 300秒を超えたら一時停止し、トリガーを設定
      scriptProperties.setProperty("startIndex", i);
      setTrigger();
      return;
    }

    const row = productData[i];
    const productNumber = row[0];
    const productName = row[1];
    const plusCode = productNumber + "_plus1";
    const minusCode = productNumber + "_minus1";

    // エラーハンドリング
    try {
      const plusQRCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(
        plusCode
      )}`;
      const minusQRCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(
        minusCode
      )}`;

      const plusQRCodeBlob = UrlFetchApp.fetch(plusQRCodeUrl).getBlob();
      const minusQRCodeBlob = UrlFetchApp.fetch(minusQRCodeUrl).getBlob();
      const currentRow = i + 2;

      qrSheet
        .getRange(currentRow, 1, 1, 2)
        .setValues([[productNumber, productName]]);
      qrSheet.setRowHeight(currentRow, cellSize);

      qrSheet.getRange(currentRow, 3).setBackground("magenta"); // 3列目の背景をマゼンタに設定
      qrSheet.getRange(currentRow, 4).setBackground("cyan"); // 4列目の背景をシアンに設定

      qrSheet.setColumnWidth(3, cellSize); // 3列目の幅を180に設定
      qrSheet.setColumnWidth(4, cellSize); // 4列目の幅を180に設定

      // 画像を挿入
      const imagePlus = qrSheet.insertImage(plusQRCodeBlob, 3, currentRow);
      const imageMinus = qrSheet.insertImage(minusQRCodeBlob, 4, currentRow);

      // QRコードをセルの中央に配置する
      const offset = (cellSize - 150) / 2; 
      imagePlus.setAnchorCellXOffset(offset).setAnchorCellYOffset(offset);
      imageMinus.setAnchorCellXOffset(offset).setAnchorCellYOffset(offset);
    } catch (e) {
      Logger.log(`エラーが発生しました: ${e.message}`);
      scriptProperties.setProperty("startIndex", i);
      setTrigger();
      return;
    }
  }

  deleteTriggerAndProperties();
  SpreadsheetApp.getUi().alert("すべてのQRコードを生成しました。");
}
function setTrigger() {
  // 既存のトリガーを削除
  ScriptApp.getScriptTriggers().forEach((trigger) => {
    if (trigger.getHandlerFunction() === "generateAllQRCodes") {
      ScriptApp.deleteTrigger(trigger);
    }
  });

  // 1分後に再実行するトリガーを設定
  ScriptApp.newTrigger("generateAllQRCodes")
    .timeBased()
    .after(1000 * 60)
    .create();
}

function deleteTriggerAndProperties() {
  // 不要なトリガーを削除
  ScriptApp.getScriptTriggers().forEach((trigger) => {
    if (trigger.getHandlerFunction() === "generateAllQRCodes") {
      ScriptApp.deleteTrigger(trigger);
    }
  });

  PropertiesService.getScriptProperties().deleteProperty("startIndex");
}
```
</details>

### QRコード読取
[jsQR](https://github.com/cozmo/jsQR)の[CDN](https://www.jsdelivr.com/package/npm/jsqr)を用いて実装しました。

### 在庫一覧表示
検索、項目別の並び替え、表示数の変更機能を実装するために[DataTables](https://datatables.net/)を使用しました。


### 処理の高速化
データを一括で取得・処理し、まとめて記載することで処理速度を向上させました。
一行ずつ取得・記載する方法ではパフォーマンスが低下するため、以下のように一括処理を行っています。

- 一括取得

```
    // 在庫管理シートからデータを一括取得
    const sheet =
      SpreadsheetApp.getActiveSpreadsheet().getSheetByName("在庫管理");
    const data = sheet.getDataRange().getValues();
```    

- 一括記載

```
    // 結果を新しいシートに一括記載
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    let monthlySheet = spreadsheet.getSheetByName(yearMonth);
    if (!monthlySheet) {
      monthlySheet = spreadsheet.insertSheet(yearMonth);
    } else {
      monthlySheet.clear(); // シートが存在する場合、内容をクリアして再作成
    }

    // ヘッダーの書き込み
    monthlySheet.getRange(1, 1).setValue(yearMonth);
    monthlySheet.appendRow(["番号", "商品名", "追加数", "消費数"]);

    // データの書き込み
    const outputData = Object.keys(inventoryMap).map((itemNumber) => {
      const item = inventoryMap[itemNumber];
      return [itemNumber, item.name, item.add, item.consume];
    });

    if (outputData.length > 0) {
      monthlySheet.getRange(3, 1, outputData.length, 4).setValues(outputData);
    }  
```

## 小話
### 既存のバーコードではなく、QRコードを利用した理由
医療材料にはGS1バーコードが使用されています。詳細は[GS1JAPANが公開している資料](https://www.gs1jp.org/assets/img/pdf/【HP掲載用】GS1チュートリアル_GS1Japan.pdf)にまとまっています。

しかし、無料でGS1バーコードを読み取り、商品情報を取得する方法が見つかりませんでした。
（関連記事：[AndroidでGS1データバーをスキャンしようとして諦めた話](https://www.wantedly.com/companies/company_8121944/post_articles/502476)）

また、取得できたとしても、診療所で扱う「単位（箱、個など）」や「発注先」の情報については、別途データを用意する必要があります。
そのため今回は、独自の商品番号を使用し、その番号に対応するQRコードを生成する方針にしました。

### 在庫データベースの作成
病院の電子カルテにはUSBが挿入できないため、許可を得たうえで、外来のパソコンで在庫一覧のExcelファイルを撮影し、OCRしてCSVファイルを出力し取り込みました。

## おわりに
今回のプロジェクトでは、低コストでのDX化に挑戦しました。
今後も医療現場の効率化に貢献できるよう、引き続き努力していきます。

＊掲載したコードは開発したものから抜粋・加工したものであり、詳細な解説は割愛しています。