種子資材ベースへの切り替えを反映しました。

frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx では、田植え計画の主選択を 品種 から 種子資材 に変更し、画面上は種子資材名だけで進めるようにしました。保存時だけ内部的に、その種子資材に紐づく Variety を解決して既存 API に送っています。候補圃場取得もその内部参照で動く形です。frontend/src/app/rice-transplant/page.tsx の一覧表示も 作物 / 品種 ではなく 種子資材 列に変更しました。

仕様書 document/16_マスタードキュメント_田植え計画編.md も 年度 × 種子資材 を軸にした説明へ更新済みです。確認できたのは Python 側の py_compile までで、フロントのビルド確認はまだしていません。Issue #2 にも今回の変更内容をコメント済みです。
This commit is contained in:
akira
2026-04-05 12:04:36 +09:00
parent 491f05eee8
commit 0131982c34
3 changed files with 75 additions and 59 deletions

View File

@@ -2,18 +2,18 @@
> **作成**: 2026-04-04
> **最終更新**: 2026-04-05
> **対象機能**: 田植え計画(年度・種を軸に複数回作成できる苗箱・種もみ使用量計画)
> **対象機能**: 田植え計画(年度・種子資材を軸に複数回作成できる苗箱・種もみ使用量計画)
> **実装状況**: MVP実装完了
---
## 概要
農業生産者が「年度 × 種」を軸に、田植え前の播種・育苗準備量を見積もる機能。
農業生産者が「年度 ×子資材」を軸に、田植え前の播種・育苗準備量を見積もる機能。
各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は種単位、反当苗箱枚数の初期値品種単位で管理する。
同じ年度・同じ種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
圃場候補は既存の作付け計画から自動取得し、選択した種子資材に紐づく品種を内部的に参照して候補圃場を決める。種もみ在庫は種子資材単位、反当苗箱枚数の初期値は紐づく品種単位で管理する。
同じ年度・同じ種子資材でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
### 機能スコープIN / OUT
@@ -24,23 +24,23 @@
| 圃場ごとの苗箱数の個別調整 | 種もみロット管理 |
| 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 |
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
| 種ごとの種もみ在庫参照 | 品種ごとの播種日管理 |
| 種子資材ごとの種もみ在庫参照 | 品種ごとの播種日管理 |
| 品種ごとの反当苗箱枚数デフォルト管理 | |
---
## 業務ルール
1. 田植え計画は `年度 × 種` を軸に作成する
2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する
3. 種もみ在庫は種単位で管理する
4. 反当苗箱枚数の初期値は品種単位で管理する
1. 田植え計画は `年度 ×子資材` を軸に作成する
2. 対象圃場は、選択した種子資材に紐づく品種の作付け計画が登録されている圃場から取得する
3. 種もみ在庫は種子資材単位で管理する
4. 反当苗箱枚数の初期値は、種子資材に紐づく品種単位で管理する
5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う
6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する
7. 実際に保存するのは圃場ごとの `苗箱数` であり、手動調整後の値をそのまま保持する
8. `種もみg/箱` は計画全体の共通パラメータとして扱い、圃場ごとには持たない
9. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
10. 同じ年度・同じ種で複数の計画を作成してよい
10. 同じ年度・同じ種子資材で複数の計画を作成してよい
11. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
---
@@ -57,7 +57,7 @@
### 計画全体の残在庫見込み
`残在庫見込み = 品種の種もみ在庫(kg) - 計画全体の種もみkg合計`
`残在庫見込み = 種子資材在庫(kg) - 計画全体の種もみkg合計`
---
@@ -79,15 +79,17 @@
| id | int | PK | |
| name | varchar(200) | required | 計画名 |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種 |
| variety | FK(plans.Variety) | PROTECT | 内部参照用の品種 |
| seedling_boxes_per_tan | decimal(6,2) | default=0 | 計画で使う反当苗箱枚数 |
| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- ユーザー操作上の主選択は `種子資材`
- 保存時には、選択した種子資材に紐づく `Variety` を内部的に参照して保持する
- `year + variety` の一意制約は持たない
- 同一年度・同一種で複数レコード作成可能
- 同一年度・同一種子資材で複数レコード作成可能
#### 表示用計算項目APIレスポンスに含まれる
@@ -96,7 +98,7 @@
| field_count | int | 対象圃場数 |
| total_seedling_boxes | decimal | 苗箱数合計 |
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
| variety_seed_inventory_kg | decimal | 種在庫(kg) |
| variety_seed_inventory_kg | decimal | 種子資材在庫(kg) |
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
### RiceTransplantEntry田植え計画エントリ
@@ -135,17 +137,17 @@
| GET | `/api/plans/rice-transplant-plans/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/plans/rice-transplant-plans/{id}/` | 更新 |
| DELETE | `/api/plans/rice-transplant-plans/{id}/` | 削除 |
| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 作付け計画から候補圃場取得 |
| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 内部参照した品種IDで候補圃場取得 |
一覧レスポンス例:
```json
{
"id": 1,
"name": "2026年度 コシヒカリ 田植え計画",
"name": "2026年度 にこまる種もみ 田植え計画",
"year": 2026,
"variety": 3,
"variety_name": "コシヒカリ",
"variety_name": "にこまる",
"crop_name": "水稲",
"seedling_boxes_per_tan": "12.00",
"default_seed_grams_per_box": "200.00",
@@ -174,7 +176,7 @@ POST/PUT リクエスト例:
```json
{
"name": "2026年度 コシヒカリ 田植え計画",
"name": "2026年度 にこまる種もみ 田植え計画",
"year": 2026,
"variety": 3,
"seedling_boxes_per_tan": "12.00",
@@ -203,7 +205,7 @@ POST/PUT リクエスト例:
|---|---|---|
| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` |
| PATCH | `/api/plans/varieties/{id}/` | `seed_material` または同等の種子在庫参照 |
| CRUD | `/api/materials/materials/?material_type=seed` | 品種別の種子在庫マスタ |
| CRUD | `/api/materials/materials/?material_type=seed` | 種子在庫マスタ |
---
@@ -213,10 +215,10 @@ POST/PUT リクエスト例:
- 年度切替
- 田植え計画の一覧表示
- 同一年度・同一種の計画が複数並ぶことを想定する
- 同一年度・同一種子資材の計画が複数並ぶことを想定する
- 表示列:
- 計画名
- 作物 / 品
-子資材
- 圃場数
- 苗箱合計
- 種もみ計画kg
@@ -229,18 +231,18 @@ POST/PUT リクエスト例:
- 基本情報:
- 計画名
- 同一年度・同一種の複数計画を区別できる名称を付ける
- 例: `2026年度 コシヒカリ 第1回`, `2026年度 コシヒカリ 4/15播種分`
- 同一年度・同一種子資材の複数計画を区別できる名称を付ける
- 例: `2026年度 にこまる種もみ 第1回`, `2026年度 にこまる種もみ 4/15播種分`
- 年度
-
-子資材
- 苗箱1枚あたり種もみ(g) デフォルト
- 備考
- 対象圃場:
- 品種選択後に作付け計画から候補圃場を自動取得
- 種子資材選択後に、その資材に紐づく品種の作付け計画から候補圃場を自動取得
- 新規作成時は候補圃場を初期選択
- 圃場の追加・除外が可能
- 初期値:
- `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan` を初期値として表示
- `反当苗箱枚数`紐づく品種マスタの `default_seedling_boxes_per_tan` を初期値として表示
- `反当苗箱枚数 × 面積(反)` で各圃場のデフォルト苗箱数を求める
- `種もみg/箱` は計画全体の共通値
- 圃場テーブル:
@@ -258,7 +260,7 @@ POST/PUT リクエスト例:
- 対象圃場数
- 苗箱合計
- 種もみ計画kg
- 種在庫kg
-子資材在庫kg
- 残在庫見込みkg
### 3. 品種管理モーダル `/allocation`
@@ -267,7 +269,12 @@ POST/PUT リクエスト例:
- 品種単位:
- 反当苗箱枚数デフォルトを更新可能
- 対応する種子在庫を設定可能
### 4. 資材マスタ `/materials/masters`
- 種子タブ:
- 種子資材を登録・編集できる
- 各種子資材に対応する品種を 1 件選んで紐付ける
---

View File

@@ -18,7 +18,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const [name, setName] = useState('');
const [year, setYear] = useState(currentYear);
const [varietyId, setVarietyId] = useState<number | ''>('');
const [seedMaterialId, setSeedMaterialId] = useState<number | ''>('');
const [seedlingBoxesPerTan, setSeedlingBoxesPerTan] = useState('');
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
const [notes, setNotes] = useState('');
@@ -39,8 +39,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
const getVariety = (id: number) =>
crops.flatMap((crop) => crop.varieties).find((variety) => variety.id === id);
const allVarieties = crops.flatMap((crop) => crop.varieties);
const getVarietyBySeedMaterial = (id: number) =>
allVarieties.find((variety) => variety.seed_material === id) ?? null;
const calculateDefaultBoxes = (field: Field, perTan: string) => {
const areaTan = parseFloat(field.area_tan || '0');
@@ -64,9 +65,12 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
if (!isNew && planId) {
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
const plan: RiceTransplantPlan = planRes.data;
const fetchedVarieties = cropsRes.data.flatMap((crop: Crop) => crop.varieties);
const linkedVariety =
fetchedVarieties.find((variety) => variety.id === plan.variety) ?? null;
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setSeedMaterialId(linkedVariety?.seed_material ?? '');
setSeedlingBoxesPerTan(plan.seedling_boxes_per_tan);
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
setNotes(plan.notes);
@@ -97,10 +101,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
useEffect(() => {
const fetchCandidates = async () => {
if (!varietyId || !year || (!isNew && loading)) return;
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : null;
if (!selectedVariety || !year || (!isNew && loading)) return;
try {
const res = await api.get(
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${selectedVariety.id}`
);
const nextCandidates: Field[] = res.data;
setCandidateFields(nextCandidates);
@@ -113,16 +118,16 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
}
};
fetchCandidates();
}, [varietyId, year, isNew, loading]);
}, [seedMaterialId, year, isNew, loading]);
useEffect(() => {
if (!varietyId) return;
const variety = getVariety(varietyId);
if (!seedMaterialId) return;
const variety = getVarietyBySeedMaterial(seedMaterialId);
if (!variety) return;
if (isNew || seedlingBoxesPerTan === '') {
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
}
}, [varietyId, crops, isNew, seedlingBoxesPerTan]);
}, [seedMaterialId, crops, isNew, seedlingBoxesPerTan]);
useEffect(() => {
const nextCalc: BoxMap = {};
@@ -206,15 +211,15 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return Number.isNaN(value) ? 0 : value;
};
const selectedVariety = varietyId ? getVariety(varietyId) : null;
const seedStock = selectedVariety?.seed_material
? seedStocks.find((item) => item.material_id === selectedVariety.seed_material) ?? null
const selectedSeedStock = seedMaterialId
? seedStocks.find((item) => item.material_id === seedMaterialId) ?? null
: null;
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : null;
const totalBoxes = selectedFields.reduce((sum, field) => sum + effectiveBoxes(field.id), 0);
const seedGrams = parseFloat(defaultSeedGramsPerBox || '0');
const totalSeedKg = seedGrams > 0 ? (totalBoxes * seedGrams) / 1000 : 0;
const seedInventoryKg = parseFloat(seedStock?.current_stock ?? '0');
const seedInventoryKg = parseFloat(selectedSeedStock?.current_stock ?? '0');
const remainingSeedKg = seedInventoryKg - totalSeedKg;
const handleSave = async () => {
@@ -223,8 +228,12 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
setError('計画名を入力してください。');
return;
}
if (!varietyId) {
setError('種を選択してください。');
if (!seedMaterialId) {
setError('種子資材を選択してください。');
return;
}
if (!selectedVariety) {
setError('選択した種子資材に対応する品種が未設定です。資材マスタで紐付けてください。');
return;
}
if (selectedFields.length === 0) {
@@ -240,7 +249,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const payload = {
name,
year,
variety: varietyId,
variety: selectedVariety.id,
seedling_boxes_per_tan: seedlingBoxesPerTan || '0',
default_seed_grams_per_box: defaultSeedGramsPerBox || '0',
notes,
@@ -349,21 +358,19 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<select
value={varietyId}
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value, 10) : '')}
value={seedMaterialId}
onChange={(e) =>
setSeedMaterialId(e.target.value ? parseInt(e.target.value, 10) : '')
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{crops.map((crop) => (
<optgroup key={crop.id} label={crop.name}>
{crop.varieties.map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
</optgroup>
{seedStocks.map((stock) => (
<option key={stock.material_id} value={stock.material_id}>
{stock.name}
</option>
))}
</select>
</div>
@@ -445,7 +452,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
<span>{totalSeedKg.toFixed(3)}kg</span>
</div>
<div className="flex justify-between text-gray-600">
<span>{seedStock?.name || selectedVariety?.seed_material_name || '種子在庫未設定'}</span>
<span>{selectedSeedStock?.name || '種子在庫未設定'}</span>
<span>{seedInventoryKg.toFixed(3)}kg</span>
</div>
<div

View File

@@ -110,7 +110,7 @@ export default function RiceTransplantPage() {
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"> / </th>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
@@ -122,7 +122,9 @@ export default function RiceTransplantPage() {
{plans.map((plan) => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
<td className="px-4 py-3 text-gray-600">
{plan.seed_material_name || '-'}
</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seedling_boxes}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seed_kg}kg</td>