クリニック向けの診察券デジタル化案件で、LINEミニアプリを使った実装に携わる機会がありました。事業者視点でのメリットは クリニックの診察券デジタル化にLINEミニアプリが向いている理由。実装事例で解説(amanity.co.jp) にまとめていますが、本記事ではその裏側にある技術的な実装ポイントをエンジニア向けに深掘りします。
具体的には以下の3点です。
- 既存の紙の診察券番号を、OCRでどう自動移行するか
- 会計完了と同時にLINE通知を送る仕組みをどう構築するか
- 複数院展開を見据えたマルチテナント設計をどう組むか
ポイント1: OCRによる診察券番号の自動読み取り・移行
課題
既存の患者データベースには紙の診察券番号がキーとして存在します。デジタル化にあたっては、この番号を患者自身に手入力させず、スマホのカメラ撮影だけで引き継げるようにする必要があります。
実装アプローチ
Google Cloud Vision API の TEXT_DETECTION を使い、撮影画像からテキストを抽出したうえで、診察券番号のフォーマット(施設ごとに異なるため正規表現で吸収)にマッチする文字列を抽出します。
import { ImageAnnotatorClient } from '@google-cloud/vision';
const visionClient = new ImageAnnotatorClient();
// 診察券番号のフォーマット例: "12-345678" のようなハイフン区切り数字
const CARD_NUMBER_PATTERN = /\b\d{2}-\d{6}\b/;
async function extractCardNumber(imageBuffer: Buffer): Promise<string | null> {
const [result] = await visionClient.textDetection({
image: { content: imageBuffer },
});
const fullText = result.textAnnotations?.[0]?.description ?? '';
const match = fullText.match(CARD_NUMBER_PATTERN);
return match ? match[0] : null;
}
OCR誤読への対策
OCRは誤読が一定確率で発生するため、以下の設計を必ず組み込みます。
-
信頼度スコアでの分岐: Vision API の
boundingPoly情報や文字ごとの confidence を見て、閾値以下の場合は自動登録せず確認画面に回す - 手動入力フォールバック: OCRで抽出できなかった場合、番号を手入力できる画面を必ず用意する(自動化率100%を前提にしない)
- 既存DBとの突合バリデーション: 抽出した番号が既存の患者データベースに実在するかをその場でチェックし、存在しない番号なら登録前にエラーを返す
async function registerCardNumber(rawNumber: string, tenantId: string) {
const patient = await db.patient.findFirst({
where: { cardNumber: rawNumber, tenantId },
});
if (!patient) {
throw new CardNumberNotFoundError(rawNumber);
}
return db.lineUser.upsert({
where: { patientId: patient.id },
update: { cardNumber: rawNumber },
create: { patientId: patient.id, cardNumber: rawNumber, tenantId },
});
}
この突合バリデーションを入れておくことで、OCRの誤読をそのまま登録してしまうリスクを防げます。
ポイント2: 会計完了時の自動LINE通知の実装
課題
従来は会計完了を電話や院内掲示板で案内していましたが、これをLINEミニアプリと連携し、会計システム側のイベントをトリガーにLINE Messaging APIのプッシュ通知へつなげる必要があります。
実装アプローチ
会計システム(レセコン・POS等)からのWebhookまたはポーリングで会計完了イベントを検知し、該当患者のLINE UIDに対してプッシュメッセージを送信します。
import { Client } from '@line/bot-sdk';
const lineClient = new Client({
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
});
async function notifyPaymentCompleted(lineUserId: string, patientName: string) {
await lineClient.pushMessage(lineUserId, {
type: 'flex',
altText: 'お会計が完了しました',
contents: {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: 'お会計が完了しました', weight: 'bold', size: 'md' },
{ type: 'text', text: `${patientName} 様`, size: 'sm', color: '#888888' },
],
},
},
});
}
実装時の注意点
- Messaging APIのレート制限: プッシュメッセージには送信数上限があるプランが存在するため、契約プランのメッセージ上限を事前に確認し、会計件数の見込みと照らし合わせる
- UIDが未登録の患者への処理: LINEミニアプリ未登録の患者は当然UIDを持たないため、通知対象外として従来の掲示板案内にフォールバックする分岐を残す
- 送信失敗時のリトライ: プッシュメッセージAPIがエラーを返した場合に備え、キューイング(例: Cloud Tasks や Bull)で再送処理を挟むと、通知漏れによるクレームを防げる
async function notifyWithRetry(lineUserId: string, patientName: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await notifyPaymentCompleted(lineUserId, patientName);
return;
} catch (err) {
if (i === maxRetries - 1) {
await fallbackToBoardNotification(patientName); // 掲示板案内へフォールバック
throw err;
}
await sleep(1000 * (i + 1));
}
}
}
ポイント3: 複数院展開に対応するマルチテナント設計
課題
医療法人が複数のクリニックを運営する場合、院ごとにロゴ・デザイン・LINE公式アカウントを分けたいというニーズがあります。1院分の実装をそのまま複数院展開できる設計にしておく必要があります。
DBスキーマ例
テナント(院)ごとの設定をテーブルで分離し、患者・診察券データにテナントIDを紐付けます。
CREATE TABLE tenants (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL, -- クリニック名
logo_url VARCHAR(500),
liff_id VARCHAR(100) NOT NULL UNIQUE, -- 院ごとのLIFF ID
line_channel_id VARCHAR(100) NOT NULL,
theme_color VARCHAR(7) -- ブランドカラー
);
CREATE TABLE patients (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
card_number VARCHAR(50) NOT NULL,
UNIQUE (tenant_id, card_number)
);
card_number の一意制約を tenant_id との複合キーにしている点がポイントです。院が異なれば同じ診察券番号が存在しうるため、テナント単位でユニーク制約を張ります。
LIFF IDからテナントを解決する
LINEミニアプリは院ごとに異なるLIFF IDを発行し、アプリ起動時にどのテナントかを解決します。
import liff from '@line/liff';
async function resolveTenant(): Promise<Tenant> {
await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! });
const context = liff.getContext();
const liffId = context?.liffId;
const tenant = await db.tenant.findUnique({ where: { liffId } });
if (!tenant) {
throw new Error(`未登録のLIFF IDです: ${liffId}`);
}
return tenant;
}
フロントエンドはこの tenant 情報からロゴ・テーマカラーを動的に反映します。ビルド時に院ごとの静的サイトを分ける方式(ホスティングが院の数だけ増える)ではなく、実行時にテナント解決する方式にしておくと、新規院追加時にコード変更なしでレコード追加のみで対応できます。
実装フロー全体像
まとめ
| 実装ポイント | 使用技術 | 注意点 |
|---|---|---|
| 診察券番号のOCR移行 | Google Cloud Vision API + 正規表現 + DB突合 | 誤読前提で手動フォールバックと突合バリデーションを必ず用意する |
| 会計完了時の自動通知 | LINE Messaging API(Push Message) | レート制限・UID未登録患者・送信失敗時のリトライ設計が必須 |
| マルチテナント対応 | テナントごとのLIFF ID + DB設計 |
card_number は tenant_id との複合ユニーク制約にする |
これらはいずれも「動くものを作る」だけでなく、誤読・未登録・送信失敗といった例外系の設計が実運用の品質を左右するポイントです。事業者視点でのメリット・導入判断については元記事もあわせてご覧ください。
元記事
クリニックの診察券デジタル化にLINEミニアプリが向いている理由。実装事例で解説(amanity.co.jp)
事業者・経営者向けのビジネス視点での解説は元記事をご覧ください。
