# マスタードキュメント：施肥計画機能

> **作成**: 2026-03-01
> **最終更新**: 2026-03-15
> **対象機能**: 施肥計画（年度×品種単位のマトリクス管理・在庫引当・散布確定）
> **実装状況**: 実装完了・本番稼働中

---

## 概要

農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当と散布確定まで一連で扱う。

### 機能スコープ（IN / OUT）

| IN（実装済み） | OUT（対象外） |
|---|---|
| 肥料マスタ管理 | 肥料購入管理 |
| 施肥計画の作成・編集・削除 | 運搬計画（→ `14_マスタードキュメント_分配計画編.md` 参照） |
| 3方式の自動計算 | 個別作業日報の詳細管理 |
| 作付け計画からの圃場自動取得 | |
| PDF出力（圃場×肥料マトリクス表） | |
| 在庫引当・引当解除 | |
| 散布確定（計画値確認 + 実績入力） | |

---

## データモデル

### Fertilizer（肥料マスタ）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| name | varchar(100) | unique, required | 肥料名 |
| maker | varchar(100) | nullable | メーカー |
| capacity_kg | decimal(8,3) | nullable | 1袋重量(kg) ← nitrogen計算に必須 |
| nitrogen_pct | decimal(5,2) | nullable | 窒素含有率(%) ← nitrogen計算に必須 |
| phosphorus_pct | decimal(5,2) | nullable | リン酸含有率(%) |
| potassium_pct | decimal(5,2) | nullable | カリ含有率(%) |
| notes | text | nullable | 備考 |

### FertilizationPlan（施肥計画）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| name | varchar(200) | required | 計画名（ユーザーが自由入力） |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種（≠NULL） |
| is_confirmed | bool | default=False | 散布確定済みフラグ |
| confirmed_at | datetime | nullable | 散布確定日時 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |

### FertilizationEntry（施肥エントリ：圃場×肥料×袋数）

| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| plan | FK(FertilizationPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| fertilizer | FK(Fertilizer) | **PROTECT** | 施肥計画で使用中の肥料は削除不可 |
| bags | decimal(8,2) | required | 袋数 |

- `unique_together = ['plan', 'field', 'fertilizer']`
- 順序: `field__display_order, field__id, fertilizer__name`

---

## API エンドポイント

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

### 肥料マスタ

| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/fertilizers/` | 一覧取得 |
| POST | `/api/fertilizer/fertilizers/` | 新規作成 |
| GET | `/api/fertilizer/fertilizers/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/fertilizer/fertilizers/{id}/` | 更新 |
| DELETE | `/api/fertilizer/fertilizers/{id}/` | 削除 |

レスポンス例（Fertilizer）:
```json
{
  "id": 1,
  "name": "コシヒカリ専用一発肥料",
  "maker": "JA",
  "capacity_kg": "20.000",
  "nitrogen_pct": "14.00",
  "phosphorus_pct": "12.00",
  "potassium_pct": "12.00",
  "notes": null
}
```

### 施肥計画

| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/fertilizer/plans/?year={year}` | 年度別一覧 |
| POST | `/api/fertilizer/plans/` | 新規作成（entries 含む） |
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得（entries 含む） |
| PUT | `/api/fertilizer/plans/{id}/` | 更新（entries 全置換） |
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定（引当 → 使用へ変換） |
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消（使用 → 引当に戻す） |
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力（application/pdf） |

一覧レスポンス例（FertilizationPlan）:
```json
{
  "id": 1,
  "name": "2025年コシヒカリ施肥計画",
  "year": 2025,
  "variety": 3,
  "variety_name": "コシヒカリ",
  "crop_name": "米",
  "is_confirmed": false,
  "confirmed_at": null,
  "field_count": 12,
  "fertilizer_count": 2,
  "entries": [
    {
      "id": 1,
      "field": 5,
      "field_name": "田中上",
      "field_area_tan": "1.2000",
      "fertilizer": 1,
      "fertilizer_name": "コシヒカリ専用一発肥料",
      "bags": "2.40"
    }
  ],
  "created_at": "2025-03-01T10:00:00Z",
  "updated_at": "2025-03-01T10:00:00Z"
}
```

POST/PUT リクエスト例:
```json
{
  "name": "2025年コシヒカリ施肥計画",
  "year": 2025,
  "variety": 3,
  "entries": [
    {"field_id": 5, "fertilizer_id": 1, "bags": 2.4},
    {"field_id": 6, "fertilizer_id": 1, "bags": 1.6}
  ]
}
```

PUT 時は entries が全置換（削除→再作成）。entries を省略した場合は既存を維持。

散布確定 API リクエスト例:
```json
{
  "entries": [
    {"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
    {"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
  ]
}
```

- `actual_bags > 0`: 対応する引当を使用実績へ変換
- `actual_bags = 0`: 未散布として引当解除

### 圃場候補取得

```
GET /api/fertilizer/candidate_fields/?year={year}&variety_id={variety_id}
```

作付け計画（Planモデル）から year + variety で圃場を検索して返す。

レスポンス例:
```json
[
  {"id": 5, "name": "田中上", "area_tan": "1.2000", "area_m2": 1200, "group_name": "田中"},
  {"id": 6, "name": "田中下", "area_tan": "0.8000", "area_m2": 800, "group_name": "田中"}
]
```

### 自動計算

```
POST /api/fertilizer/calculate/
```

計算結果を返すのみ（DB保存なし）。

#### 方式 1: per_tan（反当袋数）

```json
{
  "method": "per_tan",
  "param": 2.0,
  "field_ids": [5, 6]
}
```

計算式: `bags = Sa × A`（Sa: 反当袋数, A: 圃場面積[反]）

#### 方式 2: even（均等配分）

```json
{
  "method": "even",
  "param": 50,
  "field_ids": [5, 6]
}
```

計算式: `bags = (SS / ΣA) × A`（SS: 総袋数, A: 圃場面積[反]）

#### 方式 3: nitrogen（反当チッソ成分量）

```json
{
  "method": "nitrogen",
  "param": 3.0,
  "fertilizer_id": 1,
  "field_ids": [5, 6]
}
```

計算式: `bags = (Nr / (C × Nd/100)) × A`
- Nr: 反当チッソ成分量(kg/反)
- C: 1袋重量(kg) ← Fertilizer.capacity_kg 必須
- Nd: 窒素含有率(%) ← Fertilizer.nitrogen_pct 必須
- A: 圃場面積[反]

nitrogen 方式は capacity_kg・nitrogen_pct が未設定の肥料に対してはエラー（400）。

レスポンス（共通）:
```json
[
  {"field_id": 5, "bags": 2.40},
  {"field_id": 6, "bags": 1.60}
]
```

---

## 品種・作物の取得

品種一覧は既存の plans アプリの CropViewSet を使用:

```
GET /api/plans/crops/
```

レスポンス例:
```json
[
  {
    "id": 1,
    "name": "米",
    "base_temp": "0.0",
    "varieties": [
      {"id": 1, "name": "コシヒカリ", "crop": 1},
      {"id": 2, "name": "ヒノヒカリ", "crop": 1}
    ]
  }
]
```

**注意**: plans アプリの DefaultRouter が `r''` に登録されているため、
`/api/plans/get-crops-with-varieties/` のようなカスタムパスは 404 になる（URLルーティング競合）。
`/api/plans/crops/` を使うこと。

---

## PDF 出力

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

- WeasyPrint を使用（reports アプリと同パターン）
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/pdf.html`
- フォーマット: A4横向き
- 内容: 圃場（行）× 肥料（列）のマトリクス表、行合計・列合計・総合計
- ファイル名: `fertilization_{year}_{plan_id}.pdf`

---

## フロントエンド画面

### 施肥計画一覧（`/fertilizer`）

- 年度セレクタ（localStorage `fertilizerYear` で保持）
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
- 操作ボタン: PDF出力・編集・削除・散布確定
- ヘッダー: 「肥料マスタ」「新規作成」ボタン

### 肥料マスタ（`/fertilizer/masters`）

- 肥料一覧テーブル（名前・メーカー・容量・窒素・リン酸・カリ・備考）
- インライン行編集（EditRow コンポーネント）
- 新規追加フォーム
- 削除ボタン押下で即削除を試みる（confirm ダイアログなし）
- 施肥計画で使用中の場合は PROTECT によりバックエンドが 500 を返し、赤いインラインバナーで理由を表示

### 施肥計画編集（`/fertilizer/new` / `/fertilizer/[id]/edit`）

`FertilizerEditPage.tsx`（`fertilizer/_components/`）を共有コンポーネントとして使用。

#### 操作フロー

1. **計画基本情報入力**: 計画名・年度・品種（ドロップダウン）
2. **圃場選択**: 品種選択後に候補圃場が自動取得（`candidate_fields` API）。チップ形式で追加/解除。候補外の圃場は「全圃場から追加」で手動選択
3. **肥料追加**: 「＋肥料を追加」で肥料マスタからドロップダウン選択
4. **自動計算**: 各肥料に方式（per_tan/even/nitrogen）とパラメータを設定し「計算」ボタンでマトリクスに反映（上書き確認あり）
5. **四捨五入**: 肥料列ヘッダーの `≈` ボタン（青）を押すと袋数を整数に丸める。押した後は `↩` ボタン（琥珀色）に変わり、押すと元の計算値に戻る
6. **手動調整**: マトリクス表のセルを直接編集
7. **保存**: 「保存」ボタンで entries を一括送信

#### 在庫連携・確定状態

- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
- 「確定取消」で使用実績を引当に戻し、再編集できる

#### マトリクスの表示仕様

- 自動計算直後: セルに計算値（小数）がそのまま表示される（編集可）
- `≈` ボタン押下後: セルの入力値が整数に丸められ、元の計算値が薄いグレーで参照表示される
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る（参照グレー表示も消える）
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる

### 散布確定モーダル（`/fertilizer` 一覧から起動）

- 全画面遷移ではなくモーダル表示
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行`、`肥料 = 列` のマトリクス表を採用
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
- `0` を入力したセルは未散布として扱い、対応する引当を解除する

#### State 構成

```typescript
// 基本情報
const [name, setName] = useState('')
const [year, setYear] = useState(currentYear)
const [varietyId, setVarietyId] = useState<number | ''>('')

// 圃場・肥料
const [selectedFields, setSelectedFields] = useState<Field[]>([])
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([])

// 自動計算設定（肥料ごと）
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([])
// CalcSetting: { fertilizer_id, method: 'per_tan'|'even'|'nitrogen', param: string }

// マトリクス 2層構成（fieldId → fertilizerId → 袋数文字列）
const [calcMatrix, setCalcMatrix] = useState<Matrix>({})   // 自動計算値（参照用・変更不可表示）
const [adjusted, setAdjusted] = useState<Matrix>({})       // ユーザー確定値（保存対象）
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set())  // ↩ トグル管理

// effectiveValue(fieldId, fertId) で保存値を決定:
//   adjusted[field][fert] があればそれを優先、なければ calcMatrix[field][fert]
```

---

## ファイル構成

### Backend

```
backend/apps/fertilizer/
├── __init__.py
├── admin.py                   # Django admin 登録
├── apps.py                    # FertilizerConfig
├── models.py                  # Fertilizer, FertilizationPlan, FertilizationEntry
├── serializers.py             # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
├── views.py                   # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
├── urls.py                    # DefaultRouter + candidate_fields/ + calculate/
├── migrations/
│   ├── 0001_initial.py
│   └── 0002_alter_fertilizationentry_fertilizer.py  # CASCADE → PROTECT
└── templates/
    └── fertilizer/
        └── pdf.html           # WeasyPrint テンプレート（A4横向き）
```

### Frontend

```
frontend/src/app/fertilizer/
├── page.tsx                   # 施肥計画一覧
├── new/
│   └── page.tsx               # 新規作成（FertilizerEditPage をラップ）
├── [id]/
│   └── edit/
│       └── page.tsx           # 編集（FertilizerEditPage をラップ）
├── masters/
│   └── page.tsx               # 肥料マスタ管理
└── _components/
    └── FertilizerEditPage.tsx  # 新規/編集共通コンポーネント（複雑）
```

### 変更されたファイル

| ファイル | 変更内容 |
|---|---|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |

---

## 型定義（TypeScript）

```typescript
// frontend/src/types/index.ts

export interface Fertilizer {
  id: number;
  name: string;
  maker: string | null;
  capacity_kg: string | null;
  nitrogen_pct: string | null;
  phosphorus_pct: string | null;
  potassium_pct: string | null;
  notes: string | null;
}

export interface FertilizationEntry {
  id: number;
  field: number;
  field_name: string;
  field_area_tan: string;
  fertilizer: number;
  fertilizer_name: string;
  bags: string;
}

export interface FertilizationPlan {
  id: number;
  name: string;
  year: number;
  variety: number;
  variety_name: string;
  crop_name: string;
  field_count: number;
  fertilizer_count: number;
  entries: FertilizationEntry[];
}
```

---

## 注意点・既知の問題

### エラー表示方針（confirm/alert を使わない）

ブラウザが `alert()` / `confirm()` をブロックすると操作が無音で失敗する問題を受けて、
施肥機能全体で alert/confirm を廃止し、React インラインバナーに統一した。

- **保存失敗**: `saveError` state → ヘッダー直下に赤いバナー（✕で閉じられる）
- **肥料削除失敗**: `deleteError` state → テーブル上部に赤いバナー
- **計画削除失敗**: `deleteError` state → 一覧上部に赤いバナー
- **肥料列の除去**: ローカルstate操作のみ（失敗しない）

### URL ルーティング競合（解決済み）

plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-varieties/` を
`{pk}/` パターンとして解釈して 404 になる問題があった。
`/api/plans/crops/`（CropViewSet）を使うことで回避。

### nitrogen 計算の前提条件

反当チッソ成分量方式（nitrogen）は、指定した肥料に `capacity_kg` と `nitrogen_pct` が
両方登録されていないと 400 エラーになる。肥料マスタ登録時にユーザーへ案内が必要。

### 袋数の精度

袋数は `decimal(8,2)`（小数点以下2桁）。0.01 刻みで四捨五入。
自動計算も `Decimal.quantize(Decimal('0.01'))` で丸める。

### entries の更新方式

PUT 時は entries を全削除→再作成する「全置換」方式。
部分更新は非対応（PATCH でも entries がある場合は全置換）。

### Next.js ホットリロードが効かない問題（Windows + Docker）

Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
フロントエンドのホットリロードが動かない。

**対策**: `docker-compose.yml` の frontend 環境変数に `WATCHPACK_POLLING: "true"` を追加。
ポーリング方式に切り替えることでファイル変更を検知できるようになる。

---

## 将来の拡張（スコープ外）

- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て（別機能として検討）
- **購入管理**: 肥料の購入・在庫管理（施肥計画の集計から購入数量を自動算出）
- **作業記録との連携**: 施肥計画の実施記録（実施日・実際の袋数）
