農業アプリ開発シリーズの第3回目です。
今回は流通業者が使う「出荷先マスタ登録機能」について書いていきます。
そもそも「出荷先マスタ」って何?
出荷先マスタとは、出荷の宛先(例:○○青果株式会社、△△市場など)を一覧として管理する機能です。
これを導入した理由は明確で、「手書きによる表記ゆれの解消」「OCR結果との照合」「商社・農家間の情報統一」が必要だと思ったからです。
なぜ出荷先マスタが必要なのか?
農業の出荷管理では、同じ出荷先でも書き方がバラバラになりがちです。
よくある問題
- 「株式会社○○」「㈱○○」「○○社」
- 「A市場」「A青果市場」「A市青果市場」
- 手書き伝票での誤字・省略
これらの表記ゆれがあると、OCR・検索・集計が困難になってしまいます。
5つの設計目標
そこで、以下の目的で出荷先マスタ機能を実装しました:
① 表記ゆれの防止
統一された出荷先リストを事前に用意して、選択式にする
② プルダウン入力でミス防止
流通業者・生産者の両者がプルダウンから選べることで、誤字や選択ミスを減らす
③ OCR結果との照合のための辞書
スマホでOCRした結果(曖昧な文字列)を、このマスタの中から最も近い出荷先に紐づけるための辞書的役割
④ 出荷予定/出荷実績の共通参照先
流通業者側の「出荷予定」と生産者側の「出荷実績」の両方が、この出荷先マスタのidを参照することで、照合・差異チェックが簡単にできる
⑤ マスタの並び順・削除・追加管理
流通業者が自由に新しい出荷先を登録/並び替え/削除できるUI
データベース設計:シンプルで効果的
destinationsテーブル構造

destination_groupは将来的に「市場系」「直売系」といった分類に使う予定ですが、現在は任意入力です。
Row Level Security(RLS)設計
Supabaseの強力なRLS機能で、流通業者ごとに完全に分離しています:
-- INSERT: 流通業者は自分のIDで出荷先を登録
policy "流通業者は自分のIDで出荷先を登録"
on destinations for insert
using (created_by = auth.uid());
-- SELECT: 流通業者は自分の出荷先だけ読める
policy "流通業者は自分の出荷先だけ読める"
on destinations for select
using (created_by = auth.uid());
-- DELETE: 流通業者は自分の出荷先だけ削除できる
policy "流通業者は自分の出荷先だけ削除できる"
on destinations for delete
using (created_by = auth.uid());
この設計により:
- 各流通業者は自分が作成したマスタのみ操作可能
- 他の流通業者のマスタは見えない
- 生産者(farmer)は一切アクセス不可
フロントエンド実装:コンポーネント分離とレスポンシブ対応

コンポーネント構成
実装では機能ごとにコンポーネントを分離しました:
- DestinationPage: 状態管理・API通信の中心
- DestinationForm: 登録フォーム部分
- DestinationTable: 一覧表示・並び替え・削除
※このコンポーネント分離が本当に意味があったのか?と後になって思ったのですが、そちらはまた別の機会に。
セキュリティを重視したデータ取得
javascriptconst fetchDestinations = async (uid: string) => {
const { data, error } = await supabase
.from("destinations")
.select("*")
.eq("created_by", uid); // 自分が作成したもののみ
if (!error && data) {
setDestinations(data);
}
};
フロントエンド側でも明示的にcreated_by
で絞り込むことで、二重のセキュリティを確保しています。
リアルタイム画面更新
登録処理では、Supabaseから返されたデータを即座にローカルstateに反映:
javascriptconst handleRegister = async (name: string, group: string) => {
if (!userId) return;
const { data, error } = await supabase
.from("destinations")
.insert({
name,
destination_group: group,
created_by: userId,
})
.select(); // 追加された行を1件だけ取得
if (!error && data && data[0]) {
setDestinations([...destinations, data[0]]); // 手元の一覧に追加
}
};
この実装により、登録後の画面再読み込みが不要になり、スムーズなUXを実現できています。
日本語対応の並び替え機能
javascriptconst sorted = [...destinations].sort((a, b) => {
if (sortOrder === "name") {
return a.name.localeCompare(b.name); // 日本語の正しい並び順
} else {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}
});
localeCompare()
を使うことで、ひらがな・カタカナ・漢字が正しい順序で並びます。
「あいうえお順」と「登録順」をボタンで切り替え可能にしたのですが、
どうやら、英数字が混ざると上のコードでは順序がうまくいかない場合があるので、この辺は改善が必要な模様。
レスポンシブ対応
PC表示では表形式、モバイル表示ではカード形式に切り替わります:
javascript{/* PC表示 */}
<table className="w-full border hidden md:table">
{/* テーブル形式 */}
</table>
{/* モバイル表示 */}
<div className="block md:hidden space-y-2">
{/* カード形式 */}
</div>

Supabaseでの格納確認
実装後は、Supabaseの管理画面で実際にデータが格納されているか確認できます:
確認手順
- Supabaseダッシュボードにログイン
- 「Table Editor」→「destinations」を選択
- 登録したデータが表示されることを確認
created_by
カラムで、正しいユーザーIDが設定されているかチェック
実際に「C市場」「B株式会社」「A商事」といったテストデータを登録して、正常に動作することを確認しました。
実装してみての感想
改善の余地
現在は削除機能が即座に実行されるため、削除確認ダイアログの追加を検討しています。
また、、英数字が混ざると上のコードでは順序がうまくいかない場合があるので、この辺は改善が必要。
さらに、大量のマスタデータが登録された場合の検索・フィルター機能も必要になるかもしれません。
Ulは最小限ですが、これはまとめて装飾しようかと思っています。
現実的な開発判断:OCR連携は後回し
当初は出荷先マスタとOCR機能を同時に実装する予定でしたが、
「まず基本的なCRUD機能を完成させて、全体の流れを確認する」ことを優先しました。
この判断により:
- 流通業者・生産者双方の画面遷移を早期に確認できた
- 実際の運用で本当に必要な機能が見えてきた
- OCR精度の検証に集中できる環境が整った
完璧を求めすぎず、段階的に機能を積み重ねるアプローチの重要性を改めて感じています。
まとめ:小さな機能でも設計思想が重要
今回の出荷先マスタ登録は、一見単純なCRUD機能です。
しかし「なぜその機能が必要なのか?」「将来どう発展させるのか?」を考えて設計することで、後の拡張性が大きく変わります。
特に農業のような業界では、現場の課題を深く理解した上で技術選択することが成功の鍵だと感じています。
次回
次回は「出荷予定登録機能」について書く予定です。
流通業者が登録した出荷先マスタを、実際にプルダウン選択で使う場面が登場します。