# マスタードキュメント：運搬計画機能（旧・分配計画）

> **作成**: 2026-03-02
> **最終更新**: 2026-03-16
> **対象機能**: 運搬計画（施肥計画の肥料を軽トラで運ぶ単位で計画・記録する）
> **実装状況**: 本番稼働中

---

## 概要

施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。

### 旧設計（分配計画）からの変更理由

旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
実運用で以下のギャップが判明（2026-03-16）：

1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
2. **単一の施肥計画が分割される** - 1回で運びきれない
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
6. **作業記録でもある** - 運搬した日付を記録したい

### 機能スコープ

| IN（実装対象） | OUT（対象外） |
|---|---|
| 年度単位の運搬計画作成 | 購入管理 |
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
| 回ごとの積載合計リアルタイム表示 | |
| 圃場を回の間で移動する操作 | |
| 「残り全部」一括割り当て | |
| 回ごとの運搬日記録 | |
| PDF出力（回ごとに1ページ） | |

---

## データモデル

### 旧モデルからの移行

| 旧（削除） | 新（追加） | 備考 |
|---|---|---|
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
| DistributionGroup | DeliveryGroup | ほぼ同等 |
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
| (なし) | DeliveryTrip | 新規：運搬回 |
| (なし) | DeliveryTripItem | 新規：運搬明細（圃場×肥料単位） |

### DeliveryPlan（運搬計画）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| year | int | required | 年度 |
| name | varchar(200) | required | 計画名 |
| created_at / updated_at | datetime | auto | |

- `ordering = ['-year', 'name']`
- 施肥計画への直接FK なし（年度ベースで全施肥計画を横断）

### DeliveryGroup（配送先グループ）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| name | varchar(100) | required | グループ名（例: キウイ, 足川北） |
| order | PositiveIntegerField | default=0 | 表示順 |

- `unique_together = [['delivery_plan', 'name']]`
- `ordering = ['order', 'id']`

### DeliveryGroupField（グループ圃場割り当て）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約用 |
| group | FK(DeliveryGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |

- `unique_together = [['delivery_plan', 'field']]` → 1圃場=1グループ/1計画

### DeliveryTrip（運搬回）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| order | PositiveIntegerField | default=0 | 何回目（表示順） |
| name | varchar(100) | blank | 任意の名前（例: "たちはるか電気炉さい"） |
| date | DateField | nullable | 運搬日（デフォルト: 1回目の日付を引き継ぎ） |

- `ordering = ['order', 'id']`

### DeliveryTripItem（運搬明細）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| trip | FK(DeliveryTrip) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
| fertilizer | FK(Fertilizer) | PROTECT | |
| bags | Decimal(10,4) | required | 袋数 |

- `unique_together = [['trip', 'field', 'fertilizer']]`
- bags は施肥計画の FertilizationEntry から自動計算で初期値を設定するが、手動上書きも可能

### ER図（概念）

```
DeliveryPlan (運搬計画)
├── year, name
│
├── groups → DeliveryGroup (配送先グループ)
│   ├── name, order
│   └── fields → DeliveryGroupField → Field
│
└── trips → DeliveryTrip (運搬回)
    ├── order, name, date
    └── items → DeliveryTripItem
        ├── field → Field
        ├── fertilizer → Fertilizer
        └── bags
```

### 袋数の算出ルール

1. 運搬計画作成時、年度の全 FertilizationEntry を参照して「グループ×肥料→圃場×袋数」を自動算出
2. ユーザーが運搬回に圃場を割り当てると、該当する FertilizationEntry の bags が DeliveryTripItem.bags にコピーされる
3. 手動で bags を上書きすることも可能（施肥計画との差異は許容）
4. 「残り全部」操作: 施肥計画の合計 − 既に割り当て済みの回の合計 = 残り

---

## API エンドポイント

すべて JWT 認証（`Authorization: Bearer <token>`）。

| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/delivery/?year={year}` | 一覧（年度フィルタ） |
| POST | `/api/fertilizer/delivery/` | 新規作成 |
| GET | `/api/fertilizer/delivery/{id}/` | 詳細（groups/trips/items 込み） |
| PUT | `/api/fertilizer/delivery/{id}/` | 更新（groups・trips 全置換） |
| DELETE | `/api/fertilizer/delivery/{id}/` | 削除 |
| GET | `/api/fertilizer/delivery/{id}/pdf/` | PDF出力 |

### 一覧レスポンス

```json
{
  "id": 1,
  "year": 2026,
  "name": "2026春 肥料運搬",
  "group_count": 5,
  "trip_count": 3,
  "created_at": "...",
  "updated_at": "..."
}
```

### 詳細レスポンス

```json
{
  "id": 1,
  "year": 2026,
  "name": "2026春 肥料運搬",
  "groups": [
    {
      "id": 10,
      "name": "キウイ",
      "order": 0,
      "fields": [
        {"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
      ]
    }
  ],
  "trips": [
    {
      "id": 1,
      "order": 0,
      "name": "1回目 たちはるか電気炉さい",
      "date": "2026-03-16",
      "items": [
        {"field": 5, "fertilizer": 1, "bags": "4.00"}
      ]
    }
  ],
  "unassigned_fields": [],
  "available_fertilizers": [
    {"id": 1, "name": "電気炉さい"},
    {"id": 2, "name": "ミネラルホウ素"}
  ]
}
```

- `available_fertilizers`: 該当年度の全施肥計画で使われている肥料の一覧
- `unassigned_fields`: グループに割り当てられていない圃場

### 書き込みリクエスト（POST/PUT）

```json
{
  "year": 2026,
  "name": "2026春 肥料運搬",
  "groups": [
    {"name": "キウイ", "order": 0, "field_ids": [5, 6]}
  ],
  "trips": [
    {
      "order": 0,
      "name": "1回目",
      "date": "2026-03-16",
      "items": [
        {"field_id": 5, "fertilizer_id": 1, "bags": "4.00"}
      ]
    }
  ]
}
```

PUT は groups・trips を全削除→再作成する全置換方式。

---

## フロントエンド画面

### 運搬計画一覧 `/distribution`

- 年度セレクタ（`localStorage distributionYear` で保持）
- テーブル: 計画名・グループ数・回数
- アクション: PDF・編集・削除

### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`

#### 画面レイアウト

```
[計画名: ________________]  [年度: 2026]

━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
  (既存の方式: グループ追加・圃場割り当て・並び替え)

━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
  ☑電気炉さい  ☑ミネラルホウ素  ☐有機100号  ...
  （年度の施肥計画に含まれる肥料をチェックボックスで選択）

━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
  ★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
      圃場A  電気炉さい:2  ミネラルホウ素:3  [→1回目 ▼]
      圃場B  電気炉さい:2  ミネラルホウ素:2  [→1回目 ▼]
  ★ 足川北 (小計: 電気炉さい 12, ミネラルホウ素 6)
      圃場D  ...

━━━ 1回目 (2026-03-16) ━━━ 積載: 46袋 ━━━
  日付: [2026-03-16]  名前: [たちはるか電気炉さい]
  ★ たちはるか (小計: 電気炉さい 46)
      圃場X  電気炉さい:10  [←戻す]
      圃場Y  電気炉さい:12  [←戻す]
      ...

━━━ 2回目 (2026-03-16) ━━━ 積載: 39袋 ━━━
  日付: [2026-03-16]  名前: [____________]
  ★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
      ...

[＋回を追加]  [残り全部→新しい回]  [保存]
```

#### 主要な操作

| 操作 | 方法 | 説明 |
|---|---|---|
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
| 圃場を別の回に移動 | 圃場行の「移動...」ドロップダウン | 回の間で移動 |
| グループを回に一括割り当て | 回内の「グループを追加...」ドロップダウン | グループの全圃場を一括割り当て |
| グループを別の回に移動 | グループ★行の「移動...」ドロップダウン | グループの全圃場を回の間で一括移動 |
| グループを未割り当てに戻す | グループ★行の「移動...」→「未割り当てに戻す」 | グループの全圃場を一括で未割り当てに戻す |
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て全圃場を新しい回に追加 |
| 回の追加 | 「＋回を追加」ボタン | 空の回を追加 |
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |

#### 積載合計のリアルタイム表示

各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
圃場を追加・削除するたびに即時再計算（サーバー通信なし）。

---

## PDF 出力

`GET /api/fertilizer/delivery/{id}/pdf/`

### フォーマット

- WeasyPrint、A4横向き
- **回ごとに1ページ**（1回目=1ページ目、2回目=2ページ目...）

### 各ページの内容

```
━━━ 2回目  2026-03-16 ━━━━━━━━━━━━━━━
                      電気炉さい  ミネラルホウ素
★ キウイ                     4          5
    圃場A                    2          3
    圃場B                    2          2
★ 池田さんちの前              2          2
    圃場C                    2          2
★ 足川北                    12          6
    圃場D                    4          2
    圃場E                    4          2
    圃場F                    4          2
★ 出祥邸                     -          8
    圃場G                    -          4
    圃場H                    -          4
─────────────────────────────────────
  合計                      18         21
```

- ★行: グループ小計（肥料ごと）、太字・緑背景
- 圃場行: 各圃場の肥料ごとの袋数（**合計列なし**）
- 最下行: 回全体の肥料ごと合計
- 日付を各ページのヘッダーに記載
- ファイル名: `delivery_{year}_{plan_id}.pdf`

---

## ファイル構成（予定）

### Backend

```
backend/apps/fertilizer/
├── models.py                   # DeliveryPlan/Group/GroupField/Trip/TripItem
├── serializers.py              # Delivery* シリアライザ
├── views.py                    # DeliveryPlanViewSet
├── urls.py                     # router.register('delivery', ...)
├── admin.py                    # DeliveryPlan 等の admin 登録
├── migrations/
│   └── 000X_delivery_*.py      # 旧Distribution → 新Delivery マイグレーション
└── templates/fertilizer/
    └── delivery_pdf.html       # 回ごと1ページ PDF テンプレート
```

### Frontend

```
frontend/src/app/distribution/
├── page.tsx                            # 一覧ページ
├── new/page.tsx                        # 新規作成（ラッパー）
├── [id]/edit/page.tsx                  # 編集（ラッパー）
└── _components/DeliveryEditPage.tsx    # 編集共通コンポーネント
```

---

## マイグレーション方針

### 旧モデル（Distribution*）の扱い

1. 新モデル（Delivery*）を追加するマイグレーションを作成
2. 旧モデル（Distribution*）は削除マイグレーションで除去
3. 旧データは少量のため、データ移行は行わない（手動で再作成）

### マイグレーション順序

1. `000X_add_delivery_models.py` - DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem を追加
2. `000Y_remove_distribution_models.py` - DistributionPlan, DistributionGroup, DistributionGroupField を削除

---

## 注意点

### 施肥計画との関係

- 運搬計画は施肥計画への直接FKを持たない
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない（コピー済み）

### 集計はクライアントサイド計算

画面上の集計（グループ小計・回の積載合計）は API を呼ばずクライアントで計算。
PDF生成時のみサーバーサイドで同じ計算を実施。

### エラー表示方針

施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。

### 散布実績との連携

- 運搬計画の `DeliveryTripItem` が散布実績画面（`/fertilizer/spreading`）の候補データソースとなる
- `DeliveryTrip.date != null` の明細のみを「運搬済み」とみなし、散布候補に含める
- 散布実績画面から運搬計画を指定して遷移する場合（`?delivery_plan_id=N`）、日付フィルタは適用されない（その計画の全明細が候補になる）
- 散布実績の保存時に在庫 `USE` が作成される（運搬時点では在庫変動なし）

### WorkRecord 自動生成

- `DeliveryTrip` に日付が保存されると、`WorkRecord`（`work_type=fertilizer_delivery`）が自動生成される
- 実装: `apps/workrecords/services.py` の `sync_delivery_work_record()`
- `DeliveryTrip` の日付が削除されると、対応する `WorkRecord` も削除される
- `WorkRecord` は索引として機能し、明細データは `DeliveryTrip` / `DeliveryTripItem` 側が保持する
