在庫・出荷管理アプリを作ってます⑯:細かい改善をいろいろやった話

本業は農業ではあるのだが、どうしても農業には閑散期というものが存在する。
私が主としているアスパラガスはまさに今の時期が閑散期なのだが、
この時間を生かして栽培アプリを作りたいとは思っているものの、前にリリースした出荷アプリを触れば触るほどまあ、出てくる出てくる。改善点。

と、いうことで作ろうと思ったものの栽培アプリは全然進んでないところですが、今回は「派手じゃないけど、使ってみると便利」な機能をいくつか追加したので、まとめて紹介します。

目次

今回の改善内容

1. 出荷実績一覧に「合算表示」機能を追加

出荷管理アプリで最も使う画面の一つが「出荷実績一覧」です。日々の出荷状況を確認する画面ですが、同じ出荷先に何度も出荷していると、こんな問題がありました。

【課題】

同じ出荷先への複数回の出荷が別々の行で表示されるため、「結局、この出荷先には合計何箱出荷したの?」が分かりにくい。

例えば:

  • 2025/9/25 17:30 → A社 48箱
  • 2025/9/20 20:00 → A社 45箱

これだと、電卓で計算しないと合計が分かりません。

【解決策】

「 個別表示」と「出荷先一覧」を切り替えられるボタンを追加しました。

個別表示モード:

  • 1件ずつ詳細に表示
  • 日時、伝票番号、画像も全部見れる
  • 削除・再登録ボタンもある
  • 通常の確認作業に最適

出荷先一覧モード:

  • 出荷先ごとに合算して表示
  • 「A社 2件 最新: 2025/9/25 合計: 93箱」のように表示
  • 全期間のデータをまとめて見れる
  • 画像や削除ボタンは非表示(閲覧専用)
  • 月次集計や請求確認に便利

これで、「今月、A社にトータル何箱出荷したか?」が一目で分かるようになりました。

【実装のポイント】

出荷先とステータスでグループ化して、数量を合計する処理を実装しました。

const groupShipments = () => {
  const grouped = new Map();

  filteredShipments.forEach((shipment) => {
    const key = `${shipment.destination}-${shipment.status}`;

    if (grouped.has(key)) {
      const existing = grouped.get(key);
      existing.totalQuantity += shipment.quantity;
      existing.shipments.push(shipment);
    } else {
      grouped.set(key, {
        destination: shipment.destination,
        status: shipment.status,
        totalQuantity: shipment.quantity,
        shipments: [shipment],
      });
    }
  });

  return Array.from(grouped.values());
};

また、最新の出荷日を確実に表示するため、各グループ内の出荷データを日付降順でソートしています。

2. マルチテナント化に伴うログイン機能の再実装

開発を進めるうちにマルチテナント化(複数ユーザーが同じシステムを使える仕組み)を実装してみました。

マルチテナント化で最も重要なのが、データの完全分離です。A社のデータをB社が見れてしまったら大問題ですよね。

【RLS(Row Level Security)の厳格化】

データをユーザーごとに分離するため、SupabaseのRLS(行レベルセキュリティ)を厳格に設定しました。

RLSとは?

  • データベースのテーブルに「誰が何を見れるか」を設定する機能
  • 例: 「自分の会社のデータしか見れない」「ログインしていない人は何も見れない」

具体的には、こんなポリシーを設定:

-- ユーザーは自分自身のプロフィールのみ閲覧可能
CREATE POLICY "Users can view own profile"
ON users FOR SELECT
USING (id = auth.uid());

この設定により、ログインしていない状態(auth.uid()がnull)ではusersテーブルが一切読めなくなりました。

【問題発生: ユーザー名ログインができなくなった】

元々、ユーザー名ログインはこんなコードで実装していました:

// ユーザー名からメールアドレスを取得
const { data } = await supabase
  .from('users')
  .select('email')
  .eq('username', emailOrUsername)
  .single();

// 取得したメールアドレスでログイン
await supabase.auth.signInWithPassword({
  email: data.email,
  password
});

しかし、RLS厳格化後はこれが動かなくなりました。

理由:

  • ログインなので、まだ認証されていない(auth.uid()がnull)
  • RLSポリシーで「ログインしていない人はusersテーブルを読めない」と設定
  • → ユーザー名からメールアドレスを取得できない!

【解決策: Supabase Edge Function】

この問題を解決するため、Supabase Edge Functionを使ってRLSをバイパスする処理を実装しました。

Edge Functionとは?

  • Denoで動作するサーバーレス関数
  • Supabaseのサーバー側で動く(クライアント側ではない)
  • 「一瞬だけサーバー側で処理したい」場合に最適
  • 呼ばれた時だけ起動→処理→消える(コスト効率的)
  • SECURITY DEFINER権限でRLSをバイパスできる

実装した処理:

  1. login-helper Edge Function作成(Deno)
  2. ユーザー名を受け取り、Service Role Keyでusersテーブルにアクセス
  3. メールアドレスを返す
  4. ログイン画面でEdge Functionを呼び出し

Edge Functionとは?

  • Denoで動作するサーバーレス関数
  • 「一瞬だけサーバー側で処理したい」場合に最適
  • 呼ばれた時だけ起動→処理→消える(コスト効率的)
  • RLSをバイパスできる(SECURITY DEFINER)

実装した処理:

  1. login-helper Edge Function作成(Deno)
  2. ユーザー名を受け取り、メールアドレスを返す
  3. ログイン画面でEdge Functionを呼び出し
// ログイン画面での処理
if (!emailOrUsername.includes("@")) {
  // Edge Functionを呼び出し
  const response = await fetch(
    `${SUPABASE_URL}/functions/v1/login-helper`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${ANON_KEY}`,
      },
      body: JSON.stringify({ username: emailOrUsername }),
    }
  );

  const { email } = await response.json();
  // 取得したメールアドレスでログイン
}

【Denoについて】

開発中、エディタから「Deno executable not found」というエラーが出ることがありました。

なぜエラーが出た?

  • Edge Functionを作る時、Deno用のVS Code設定を生成していた
  • でも、Deno本体はインストールしていなかった

なぜ今まで動いていた?

  • Edge FunctionはSupabaseのクラウドで動く
  • ローカルでEdge Functionをテストしない限り、Denoは不要
  • デプロイもSupabase CLIが代行してくれる

結論: エラーは無視してOK。Edge Functionを編集したくなったら、その時にDenoをインストールすればいい。

# 将来必要になったら
brew install deno

3. パスワード入力欄に表示切り替えボタン追加

ログイン画面のパスワード入力欄に、表示/非表示を切り替えるボタンを追加しました。

【Before】

入力ミスしても、どこが間違ってるか分からない…

【After】

目のアイコンをクリックすると、パスワードが見える/見えないを切り替えられます。

【実装】

まず、表示/非表示を管理するステートを追加:

const [showPassword, setShowPassword] = useState(false);

入力欄のタイプを切り替え:

<input 
  type={showPassword ? "text" : "password"}
  value={password}
  onChange={(e) => setPassword(e.target.value)}
/>

アイコンボタンを追加(lucide-reactを使用):

import { Eye, EyeOff } from "lucide-react";

<button
  type="button"
  onClick={() => setShowPassword(!showPassword)}
>
  {showPassword ? <EyeOff /> : <Eye />}
</button>

クリックするたびにshowPasswordがtrue/falseと切り替わり、inputのtypeがpasswordtextと変わります。

地味ですが、モバイルでの入力ミスが減りました。

4. 削除機能を強化(一括削除対応)

出荷実績の削除機能を強化し、複数の出荷を一括で削除できるようにしました。

【実装内容】

  • 「削除モード」ボタンで切り替え
  • チェックボックスで複数の出荷を選択
  • 「選択した項目を削除(○件)」ボタンで一括削除
  • 権限制御付き(流通業者・管理者のみ)

【使い方】

  1. 出荷実績一覧で「削除モード」をクリック
  2. 削除したい出荷にチェックを入れる
  3. 画面下部の「選択した項目を削除」ボタンをクリック
  4. 確認ダイアログで「削除」を押す

誤登録した出荷をまとめて削除できるので、作業効率が大幅に向上しました。

5. OCR機能をさらに強化

Google Vision APIを使った伝票読み取り機能(OCR)の精度をさらに向上させました。

【改善点1: 正規化処理の強化】

出荷先名の表記揺れに対応するため、正規化処理を大幅に強化しました。

追加した処理:

  • カタカナ表記揺れの統一(ヴ→ブ、ヴァ→バ、ウェ→ウエなど)
  • 「株式会社」のバリエーションをすべて削除(㈱、(株)、株)など)
  • カッコ・記号類の統一と削除
  • 全角アルファベットの半角化
function normalize(text: string): string {
  return text
    // カタカナ表記揺れ統一
    .replace(/ウェ/g, "ウエ")    // ウェル → ウエル
    .replace(/ヴ/g, "ブ")        // ヴィラ → ビラ
    .replace(/ヶ(?!月)/g, "ケ")  // ヶ → ケ

    // 株式会社を削除
    .replace(/株式会社|株式會社|㈱|(株)|\(株\)|株)|(株/g, "")

    // カッコ・記号類の統一
    .replace(/[・・]/g, "")
    .replace(/[~~〜]/g, "")
    .replace(/&/g, "")

    // 空白削除
    .replace(/[ \s]+/g, "")

    // 小文字統一
    .toLowerCase();
}

効果: マスタ登録時に「株式会社」の有無を気にしなくて済むようになりました。また、表記揺れにも強くなったため、認識精度が向上しました。

【改善点2: 出荷先候補の選定ロジック改善】

OCRで読み取ったテキストから、出荷先名を抽出するロジックに優先順位を明確化しました。

優先順位:

  1. 「株式会社」または「㈱」を含む行
  2. 「様」を含む行
  3. カタカナ・ひらがな・漢字を含む適度な長さの行
function pickLikelyDestination(text: string): string | null {
  const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);

  // 優先順位1: 「株式会社」または「㈱」を含む行
  for (const line of lines) {
    const normalized = normalize(line);
    if (
      (normalized.includes("株式会社") || normalized.includes("㈱")) &&
      line.length >= 3 &&
      line.length <= 25
    ) {
      return line.replace(/様$/, "").trim();
    }
  }

  // 優先順位2: 「様」を含む行
  for (const line of lines) {
    if (line.includes("様") && line.length >= 3 && line.length <= 25) {
      return line.replace(/様$/, "").trim();
    }
  }

  // 優先順位3: その他の候補
  // ...
}

効果: より正確に出荷先名を抽出できるようになりました。特に、企業名を含む伝票の認識精度が大幅に向上しました。

学んだこと

1. 「合算表示」の実装パターン

JavaScriptのMapを使ったグループ化パターンは、様々な場面で応用できます。出荷先だけでなく、日付別、ステータス別など、集計軸を変えるだけで色々な集計が可能になります。

2. Edge Functionの使いどころ

Edge Functionは以下のような場面で活躍します:

  • RLSをバイパスする必要がある処理
  • セキュアな認証・認可ロジック
  • 外部API呼び出し(APIキーを隠す)
  • 画像処理、OCR処理など

3. Denoの立ち位置

  • Supabase Edge Function = Deno専用
  • ローカルで開発する場合のみDenoが必要
  • デプロイだけならDenoなしでもOK
  • 本番環境はSupabaseのサーバーで動く

4. OCR精度向上の鍵は「正規化」

OCRの認識精度を上げるには、以下が重要です:

  • 表記揺れのパターンを洗い出す
  • 正規化ルールを体系的に整理
  • 優先順位を明確にする
  • 実際のデータで検証を繰り返す

おわりに

今回の改善は、派手な新機能ではなく「使いやすさの積み重ね」でした。

  • 出荷先一覧で合計がすぐ分かる
  • ユーザー名でログインできる
  • パスワードを確認しながら入力できる
  • 複数の出荷をまとめて削除できる
  • OCRの認識精度が上がる

どれも小さな改善ですが、毎日使うアプリだからこそ、こういった細かい使い勝手が積み重なって大きな違いになるかと考えています。


目次