作成: 2026-03-01
最終更新: 2026-05-22
対象機能: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布実績記録)
実装状況: 実装完了・本番稼働中(散布実績連携追加)
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力・在庫引当・散布実績記録・作業記録索引生成まで一連で扱う。
| IN(実装済み) | OUT(対象外) |
|---|---|
| 肥料マスタ管理 | 肥料購入管理 |
| 施肥計画の作成・編集・削除 | 運搬計画(→ 14_マスタードキュメント_分配計画編.md 参照) |
| 3方式の自動計算 | 運搬便ごとの散布充当追跡 |
| 作付け計画からの圃場自動取得 | 相手先ごとのPDF様式実装 |
| PDF出力(圃場×肥料マトリクス表) | 残肥返却・再入庫管理 |
| 在庫引当・引当解除 | |
| 散布実績記録(日付単位・運搬済み肥料ベース) | |
| 作業記録索引(WorkRecord)自動生成 | |
| 在庫USE連携(散布実績保存時) | |
| 施肥計画進捗表示(未散布/一部散布/完了/計画超過) |
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| 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 | 備考 |
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| 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 |
| 項目 | 型 | 説明 |
|---|---|---|
| spread_status | string | unspread / partial / completed / over_applied |
| planned_total_bags | decimal | 計画袋数合計(全entries.bagsの合計) |
| spread_total_bags | decimal | 散布済み袋数合計(全entries.actual_bagsの合計) |
| remaining_total_bags | decimal | 残袋数(planned_total_bags - spread_total_bags) |
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| plan | FK(FertilizationPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| fertilizer | FK(Fertilizer) | PROTECT | 施肥計画で使用中の肥料は削除不可 |
| bags | decimal(8,2) | required | 袋数(計画値) |
| actual_bags | decimal(10,4) | nullable | 散布実績集計値(SpreadingSessionItemから自動集計) |
unique_together = ['plan', 'field', 'fertilizer']field__display_order, field__id, fertilizer__name| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| year | int | required | 年度フィルタ用 |
| date | DateField | required | 散布日 |
| name | varchar(100) | required | セッション名(必須) |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto |
year + date の一意制約は付けない(同日に午前・午後やエリア別で複数記録可能)| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| session | FK(SpreadingSession) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
| fertilizer | FK(Fertilizer) | PROTECT | |
| actual_bags | decimal(10,4) | required | 実散布袋数 |
| planned_bags_snapshot | decimal(10,4) | required | 表示時点の計画値 |
| delivered_bags_snapshot | decimal(10,4) | required | 表示時点の運搬済み合計 |
| created_at | datetime | auto | |
| updated_at | datetime | auto |
unique_together = ['session', 'field', 'fertilizer']別アプリ apps/workrecords/ で管理。施肥・運搬の作業を日付順に一覧するための索引テーブル。
詳細の本体は各業務テーブル側(DeliveryTrip / SpreadingSession)に持つ。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| work_date | DateField | required | 作業日 |
| work_type | varchar | required | fertilizer_delivery / fertilizer_spreading |
| title | varchar(200) | required | 一覧表示名 |
| year | int | required | 年度フィルタ補助 |
| auto_created | bool | default=True | 自動生成フラグ |
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
| created_at | datetime | auto | |
| updated_at | datetime | auto |
すべて 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):
{
"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):
{
"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 リクエスト例:
{
"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 を省略した場合は既存を維持。
| メソッド | URL | 説明 |
|---|---|---|
| GET | /api/fertilizer/spreading/?year={year} |
年度別一覧 |
| POST | /api/fertilizer/spreading/ |
新規作成 |
| GET | /api/fertilizer/spreading/{id}/ |
詳細 |
| PUT | /api/fertilizer/spreading/{id}/ |
更新 |
| DELETE | /api/fertilizer/spreading/{id}/ |
削除 |
| GET | /api/fertilizer/spreading/candidates/?year={year} |
散布候補一覧 |
散布候補一覧レスポンス例:
[
{
"field": 5,
"field_name": "田中上",
"fertilizer": 1,
"fertilizer_name": "電気炉さい",
"planned_bags": "4.0000",
"delivered_bags": "4.0000",
"spread_bags": "1.5000",
"remaining_bags": "2.5000",
"remaining_plan_bags": "2.5000",
"delivery_gap": "0.0000"
}
]
散布実績 POST リクエスト例:
{
"year": 2026,
"date": "2026-04-15",
"name": "午前・田中エリア",
"notes": "",
"items": [
{
"field": 5,
"fertilizer": 1,
"actual_bags": "2.5000",
"planned_bags_snapshot": "4.0000",
"delivered_bags_snapshot": "4.0000"
}
]
}
| メソッド | URL | 説明 |
|---|---|---|
| GET | /api/workrecords/?year={year} |
一覧 |
| GET | /api/workrecords/{id}/ |
詳細(元レコードへのリンク情報を返す) |
GET /api/fertilizer/candidate_fields/?year={year}&variety_id={variety_id}
作付け計画(Planモデル)から year + variety で圃場を検索して返す。
レスポンス例:
[
{"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保存なし)。
{
"method": "per_tan",
"param": 2.0,
"field_ids": [5, 6]
}
計算式: bags = Sa × A(Sa: 反当袋数, A: 圃場面積[反])
{
"method": "even",
"param": 50,
"field_ids": [5, 6]
}
計算式: bags = (SS / ΣA) × A(SS: 総袋数, A: 圃場面積[反])
{
"method": "nitrogen",
"param": 3.0,
"fertilizer_id": 1,
"field_ids": [5, 6]
}
計算式: bags = (Nr / (C × Nd/100)) × A
nitrogen 方式は capacity_kg・nitrogen_pct が未設定の肥料に対してはエラー(400)。
レスポンス(共通):
[
{"field_id": 5, "bags": 2.40},
{"field_id": 6, "bags": 1.60}
]
品種一覧は既存の plans アプリの CropViewSet を使用:
GET /api/plans/crops/
レスポンス例:
[
{
"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/ を使うこと。
GET /api/fertilizer/plans/{id}/pdf/
backend/apps/fertilizer/templates/fertilizer/pdf.htmlfertilization_{year}_{plan_id}.pdf/fertilizer)fertilizerYear で保持)未散布 / 一部散布 3.5 / 8.0袋 / 散布完了 / 計画超過/fertilizer/masters)/fertilizer/new / /fertilizer/[id]/edit)FertilizerEditPage.tsx(fertilizer/_components/)を共有コンポーネントとして使用。
candidate_fields API)。チップ形式で追加/解除。候補外の圃場は「全圃場から追加」で手動選択≈ ボタン(青)を押すと袋数を整数に丸める。押した後は ↩ ボタン(琥珀色)に変わり、押すと元の計算値に戻るbags(計画値)を編集可能、actual_bags(実績値)は読み取り専用で参照表示/fertilizer/records)へのリンクを表示≈ ボタン押下後: セルの入力値が整数に丸められ、元の計算値が薄いグレーで参照表示される↩ ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)adjusted と roundedColumns がリセットされる/fertilizer/records)spreadingYear と連動)FertilizationEntry.actual_bags 再集計を実行#75)matrixFields / matrixFertilizers は全圃場・全肥料を保持するdisplayFields / displayFertilizers にのみ選択フィルターを適用するselectedRows、保存 payload、総合計(「入力中 N件 / 合計 X袋」)は全件ベースで算出する(フィルター適用中でも乖離しない)X/N件 のカウントを表示するselectedFertilizerIds: Set<number> / selectedFieldIds: Set<number>(全 ID = 全表示、空集合 = 全非表示)/workrecords)// 基本情報
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/apps/fertilizer/
├── __init__.py
├── admin.py # Django admin 登録
├── apps.py # FertilizerConfig
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry, SpreadingSession, SpreadingSessionItem
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer, SpreadingSessionSerializer
├── services.py # actual_bags再集計、WorkRecord自動生成、在庫USE連携
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, SpreadingSessionViewSet, CandidateFieldsView, CalculateView
├── urls.py # DefaultRouter + candidate_fields/ + calculate/ + spreading/
├── migrations/
│ ├── 0001_initial.py
│ ├── 0002_alter_fertilizationentry_fertilizer.py # CASCADE → PROTECT
│ └── ... # SpreadingSession, SpreadingSessionItem, actual_bags 追加
└── templates/
└── fertilizer/
└── pdf.html # WeasyPrint テンプレート(A4横向き)
frontend/src/app/fertilizer/
├── page.tsx # 施肥計画一覧
├── new/
│ └── page.tsx # 新規作成(FertilizerEditPage をラップ)
├── [id]/
│ └── edit/
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
├── masters/
│ └── page.tsx # 肥料マスタ管理
├── records/
│ └── page.tsx # 施肥実績画面(一覧・作成・編集)
└── _components/
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
frontend/src/app/workrecords/
└── ... # 作業記録画面(一覧・詳細)
| ファイル | 変更内容 |
|---|---|
backend/keinasystem/settings.py |
INSTALLED_APPS に 'apps.fertilizer', 'apps.workrecords' を追加 |
backend/keinasystem/urls.py |
api/fertilizer/, api/workrecords/ を追加 |
backend/apps/materials/models.py |
StockTransaction.spreading_item FK 追加(on_delete=SET_NULL) |
backend/apps/workrecords/ |
作業記録索引アプリ(WorkRecord モデル・API・services) |
frontend/src/types/index.ts |
施肥・散布・作業記録の型を追加 |
frontend/src/components/Navbar.tsx |
Sprout アイコン + 施肥計画メニューを追加 |
bags ベースで維持SpreadingSessionItem ごとに USE を1件作成material: item.fertilizer.materialquantity: actual_bagsoccurred_on: session.datenote: 散布実績「{session.name or session.date}」spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)bags ベースactual_bags ベースFertilizationEntry.bags の合計
DeliveryTrip.date != null の DeliveryTripItem.bags 合計
SpreadingSessionItem.actual_bags の合計
SUM(SpreadingSessionItem.actual_bags) を同一 year, field, fertilizer で集計FertilizationEntry.actual_bags を即時再計算SUM(...) = 0 の場合は actual_bags = nulldelivered_total - spread_total
planned_total - spread_total
remaining_bags < 0: 運搬実績不足remaining_plan_bags < 0: 計画超過DeliveryTrip.date 保存時に upserttitle = 肥料運搬: {delivery_plan.name} {n}回目SpreadingSession 保存時に upserttitle = 肥料散布: {session.name or session.date}自動生成は view に直書きせず、サービス層(services.py)で idempotent に実装する。
copy_from_previous_year で前年度の FertilizationEntry をコピーする際のルール:
actual_bags がある場合: actual_bags を新年度の bags 初期値として使用actual_bags が null の場合: 従来どおり bags をコピー前年度に実際に散布した量を次年度計画の初期値として再利用できる。
// 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;
actual_bags: string | null; // 散布実績集計値
}
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[];
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
planned_total_bags: string;
spread_total_bags: string;
remaining_total_bags: string;
}
ブラウザが alert() / confirm() をブロックすると操作が無音で失敗する問題を受けて、
施肥機能全体で alert/confirm を廃止し、React インラインバナーに統一した。
saveError state → ヘッダー直下に赤いバナー(✕で閉じられる)deleteError state → テーブル上部に赤いバナーdeleteError state → 一覧上部に赤いバナーplans アプリの DefaultRouter(r'', PlanViewSet) が plans/get-crops-with-varieties/ を
{pk}/ パターンとして解釈して 404 になる問題があった。
/api/plans/crops/(CropViewSet)を使うことで回避。
反当チッソ成分量方式(nitrogen)は、指定した肥料に capacity_kg と nitrogen_pct が
両方登録されていないと 400 エラーになる。肥料マスタ登録時にユーザーへ案内が必要。
袋数は decimal(8,2)(小数点以下2桁)。0.01 刻みで四捨五入。
自動計算も Decimal.quantize(Decimal('0.01')) で丸める。
PUT 時は entries を全削除→再作成する「全置換」方式。
部分更新は非対応(PATCH でも entries がある場合は全置換)。
RESERVE(計画値 bags ベース)USE(実績値 actual_bags ベース)RESERVE と USE は併存する(計画値と実績値は別管理)session に紐づく USE を全置換で作り直すUSE を削除する(StockTransaction.spreading_item は SET_NULL)perform_destroy で明示的に StockTransaction を削除してから session.delete() を呼ぶSpreadingSession.name は必須フィールド。WorkRecord のタイトル生成や一覧表示に使用するため、
空文字での保存は許可しない。
施肥実績画面(/fertilizer/records)では useSearchParams() を使用するため、
Suspense boundary でラップする必要がある(本番ビルドで必須)。
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
フロントエンドのホットリロードが動かない。
対策: docker-compose.yml の frontend 環境変数に WATCHPACK_POLLING: "true" を追加。
ポーリング方式に切り替えることでファイル変更を検知できるようになる。
Issue #72 では、田植え計画を初回適用として設計した
計画横断調整フロー を、施肥計画にも横展開する前提を置く。
施肥計画での主な適用点は次のとおり。
散布実績は計画に従属しない
SpreadingSession / SpreadingSessionItem は実績正本として独立に残す残りの計画だけを再編する
field × fertilizer はロック対象計画変更は差分整理として扱う
共通方針は docs/27_マスタードキュメント_計画横断調整編.md を参照。