栽培管理アプリを作ってます⑯:畝の数だけ行が増える問題、scopeカラムで解決した

今回は、作業記録のデータベース設計を見直した話です。「ハウス全体に同じ作業をしたのに、なぜ畝の数だけデータが増えるのか」という問題に向き合い、設計を改善しました。

目次

そもそも何が問題だったのか

私のアプリでは、農作業の記録(灌水・施肥・防除など)を「どのハウスの、どの畝で、何をしたか」という単位でデータベースに保存しています。

たとえば、No.1ハウスに畝が4本あるとします。全畝に同じ灌水作業をした場合、今まではこうなっていました。

レコードIDハウス作業内容
1No.1畝1灌水
2No.1畝2灌水
3No.1畝3灌水
4No.1畝4灌水

同じ内容なのに4行。ハウスが6棟あって毎日作業すると、1年間で数千件のデータになります。分かってはいたけど、改めて見ると「これは整理したい」と思いました。

3つの設計案を比較した

解決策として3つの案を検討しました。

案①:作業本体と対象畝を別テーブルに分ける

「何をしたか」は1行だけ保存して、「どの畝でやったか」を別テーブルに持たせる方法です。

-- 作業本体(全畝選択でも1行)
cult_log_events: id, field_id, log_date, log_type, ...

-- 対象畝(畝の数だけ行が増えるが、IDだけなので軽い)
cult_log_targets: event_id, bed_id

データが正規化されていて、後から「この畝に何回作業したか」という畝単位の分析も正確にできます。ただし、テーブルが2つになるため、保存・取得のコードが複雑になります。

案②:畝IDを配列で持つ

PostgreSQL(Supabaseが使っているデータベース)には配列型があるので、1行に複数の畝IDをまとめて保存できます。

bed_ids: [1, 2, 3, 4]  -- 配列で保存

テーブルは増えませんが、検索や集計のクエリが複雑になります。今回は見送りました。

案③:「全畝」というフラグを持たせる(採用)

全畝選択のときは畝IDをNULLにして、代わりにscope(範囲)というカラムで「これは全畝の作業だ」と記録する方法です。

scopebed_id意味
all_bedsNULLハウス全畝
single_bed384特定の1畝

全畝選択なら1行で済む。シンプルで既存コードへの影響も最小限です。

なぜ案③を選んだのか

案①が設計としては理想的です。ただ、私のアプリでは農薬の記録だけ最初から別テーブル管理していました。

農薬には「年間使用回数の制限」があります(農薬取締法)。そのため農薬の使用記録は、畝ごとに正確にカウントできる設計が必要でした。以前の記事でも紹介しましたが、作業ログ本体(cult_logs)と農薬詳細(cult_log_pesticides)を分けて管理しています。

-- 作業記録(灌水・施肥・防除など)
cult_logs: id, field_id, bed_id, log_date, log_type, ...

-- 農薬の詳細記録(どの農薬を何ml使ったか)
cult_log_pesticides: log_id, pesticide_id, bed_id, original_amount, ...

農薬はすでに畝ごとに正確に記録できている。作業ログ(灌水・施肥・作業など)はハウス単位で記録すれば十分。だから案③で十分だと判断しました。

もし将来「畝ごとの肥料使用量を分析したい」という要件が出てきたら、そのときは畝ごとに登録すればいいだけです。全畝選択を使わなければ、従来通り畝ごとにバラバラで登録できます。

やりたいことに合わせて登録方法を使い分けるだけ。「全畝に同じ作業」→まとめて1行。「畝ごとに違う内容」→分けて登録。シンプルなルールです。

実装:scopeカラムを追加する

データベースにカラムを追加

まずSupabaseのSQLエディタで1行実行するだけです。

ALTER TABLE cult_logs
ADD COLUMN scope text DEFAULT 'single_bed';

既存データへの影響はなし。新しいレコードからscopeが保存されるようになります。

登録処理(actions.ts)の変更

全畝選択のときは畝ごとに展開せず、1行だけINSERTするように変更しました。

// 全畝のリストを作成(all_bedsの場合は1行にまとめる)
const allBeds = fields.flatMap((field) => {
  if (field.scope === "all_beds") {
    return [{
      fieldId: field.fieldId,
      bedId: null,           // 畝IDはNULL
      area: field.beds.reduce((sum, b) => sum + b.area, 0), // 全畝の合計面積
      scope: "all_beds",
    }];
  }
  // 通常は畝ごとに展開
  return field.beds.map((bed) => ({
    fieldId: field.fieldId,
    bedId: bed.bedId,
    area: bed.area,
    scope: field.scope,
  }));
});

注意点として、農薬の記録だけは全畝選択でも畝ごとに展開したままにしています。農薬の使用回数カウントは畝単位で正確に行う必要があるためです。

// 農薬だけは常に畝単位で展開(使用回数カウントのため)
const expandedBedsForPesticide = fields.flatMap((field) =>
  field.beds.map((bed) => ({
    fieldId: field.fieldId,
    bedId: bed.bedId,
    area: bed.area,
    scope: field.scope,
  }))
);

// 防除のみ畝展開リストを使用、それ以外はallBedsを使用
const bedsForLogs = workType === "防除" 
  ? expandedBedsForPesticide 
  : allBeds;

画面側(FieldsAClient.tsx)の変更

「全選択ボタンを押したか」を判定して、scopeを自動的に設定します。

fields: selectedBeds.map((field) => {
  const allFieldBeds = fields.find(f => f.id === field.fieldId)?.beds || [];
  // 選択した畝がハウスの全畝と一致するかチェック
  const isAllSelected = 
    allFieldBeds.length > 0 &&
    allFieldBeds.length === field.beds.length &&
    allFieldBeds.every(fb => field.beds.some(b => b.bedId === fb.id));

  return {
    fieldId: field.fieldId,
    scope: isAllSelected ? "all_beds" : "single_bed",
    beds: field.beds.map((bed) => ({
      bedId: bed.bedId,
      area: bed.area,
    })),
  };
})

複数作業者の記録とsession_id

実はデータが増える原因がもう一つあります。複数人で同じ作業をしたときです。

「AさんとBさんで60分、No.1ハウス全畝に灌水した」という場合、DBには2レコード入ります。

作業内容作業者scope
灌水Aさんall_beds
灌水Bさんall_beds

これは「誰がやったか」を記録するために必要なので減らせません。ただ、作業履歴画面で2行をまとめて1枚のカードに表示したいという問題が出てきました。

解決策としてsession_idというカラムを使いました。同じタイミングで登録した複数人のレコードに、同じUUID(ランダムな識別子)を付けておきます。

// 複数人登録のときだけsession_idを発行
const sessionId = workerIds.length > 1 ? crypto.randomUUID() : null;

// 全員に同じsession_idを付けてINSERT
logs = workerIds.flatMap((workerId) =>
  allBeds.map((bed) => ({
    ...
    worker_id: workerId,
    session_id: sessionId, // 同じIDが全員に付く
  }))
);

作業履歴画面ではsession_idが同じレコードをグルーピングして1枚のカードに表示。「Aさん・Bさん(60分)」とまとめて見せることができます。

ちなみにこのsession_id、最初はplanting_idという別の目的で作ったカラムの再利用を検討しました。ただ名前が紛らわしいため、新たにsession_idとして追加しました。

ハマったところ:修正モードで全畝ログが復元できない

実装後、「作業履歴から修正して再登録」する機能で問題が発生しました。全畝登録したログを修正しようとすると、モーダルが開かないのです。

原因はログの復元処理にありました。修正モードでは「過去のログを取得して、登録画面に選択状態を再現する」という処理をしています。

// 問題のあったコード
for (const log of logs) {
  const bedId = log.cult_beds.id;   // bed_id=NULLだとcult_bedsがNULLになりエラー!
  const bedName = log.cult_beds.name;
}

全畝登録のログはbed_idがNULLなので、畝テーブルとの結合結果(cult_beds)もNULLになります。そこにアクセスしようとしてエラーが発生し、モーダルが開く前にクラッシュしていました。

修正はシンプルです。cult_bedsがNULLの場合(=全畝登録)は、ハウスマスタから全畝を取得して復元するようにしました。

for (const log of logs) {
  if (!log.cult_beds) {
    // 全畝登録の場合:fieldsから全畝を取得して復元
    const field = fields.find(f => f.id === fieldId);
    if (field) {
      field.beds.forEach(bed => {
        fieldData.beds.push({
          bedId: bed.id,
          bedName: bed.name,
          area: bed.width_m * bed.length_m,
        });
      });
    }
    continue;
  }
  // 通常の畝指定の場合
  const bedId = log.cult_beds.id;
  const bedName = log.cult_beds.name;
  ...
}

結果と今後の課題

今回の改善で、全畝選択時のレコード数が大幅に削減できました。

条件改善前改善後
4畝ハウスで全畝作業4レコード1レコード
6棟×毎日×1年約8,760件約2,190件

修正モードの復元も正常に動作するようになり、全畝ログの修正・再登録ができるようになりました。

今後の課題としては、work-history(作業履歴)画面での表示を全畝ログに対応させることです。現在は「全畝」と表示できていますが、フィルタやグルーピングの一部でまだ対応が必要な箇所があります。

まとめ

データベース設計は「完璧な正規化」より「今の要件に合った設計」が大切だと改めて感じました。農薬は畝単位の正確な記録が必要だから別テーブル管理、作業ログはハウス単位で十分だからscopeフラグで対応。用途に合わせて使い分けることが実用的な設計につながります。

次回は引き続き作業履歴画面の改善を進めていきます。

目次