Adjust rice transplant plan to store installed box counts

This commit is contained in:
akira
2026-04-05 10:26:14 +09:00
parent 9bcc5e5e21
commit 95c90dd699
6 changed files with 101 additions and 50 deletions

View File

@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('plans', '0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant'),
]
operations = [
migrations.RenameField(
model_name='ricetransplantentry',
old_name='seedling_boxes_per_tan',
new_name='installed_seedling_boxes',
),
]

View File

@@ -97,10 +97,10 @@ class RiceTransplantEntry(models.Model):
related_name='rice_transplant_entries',
verbose_name='圃場',
)
seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
installed_seedling_boxes = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name='反当苗箱枚数',
verbose_name='設置苗箱枚数',
)
seed_grams_per_box = models.DecimalField(
max_digits=8,
@@ -115,4 +115,4 @@ class RiceTransplantEntry(models.Model):
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.plan} / {self.field} / {self.seedling_boxes_per_tan}/反'
return f'{self.plan} / {self.field} / {self.installed_seedling_boxes}'

View File

@@ -50,6 +50,7 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
)
planned_boxes = serializers.SerializerMethodField()
planned_seed_kg = serializers.SerializerMethodField()
default_seedling_boxes = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantEntry
@@ -58,20 +59,25 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
'field',
'field_name',
'field_area_tan',
'seedling_boxes_per_tan',
'installed_seedling_boxes',
'seed_grams_per_box',
'default_seedling_boxes',
'planned_boxes',
'planned_seed_kg',
]
def get_planned_boxes(self, obj):
def get_default_seedling_boxes(self, obj):
area = Decimal(str(obj.field.area_tan))
return str((area * obj.seedling_boxes_per_tan).quantize(Decimal('0.01')))
default_boxes_per_tan = obj.plan.variety.default_seedling_boxes_per_tan
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
def get_planned_boxes(self, obj):
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
def get_planned_seed_kg(self, obj):
area = Decimal(str(obj.field.area_tan))
boxes = area * obj.seedling_boxes_per_tan
seed_kg = (boxes * obj.seed_grams_per_box / Decimal('1000')).quantize(Decimal('0.001'))
seed_kg = (
obj.installed_seedling_boxes * obj.seed_grams_per_box / Decimal('1000')
).quantize(Decimal('0.001'))
return str(seed_kg)
@@ -111,7 +117,7 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
def get_total_seedling_boxes(self, obj):
total = sum(
Decimal(str(entry.field.area_tan)) * entry.seedling_boxes_per_tan
entry.installed_seedling_boxes
for entry in obj.entries.all()
)
return str(total.quantize(Decimal('0.01')))
@@ -119,8 +125,7 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
def get_total_seed_kg(self, obj):
total = sum(
(
Decimal(str(entry.field.area_tan))
* entry.seedling_boxes_per_tan
entry.installed_seedling_boxes
* entry.seed_grams_per_box
/ Decimal('1000')
)
@@ -186,6 +191,6 @@ class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
RiceTransplantEntry.objects.create(
plan=plan,
field_id=entry['field_id'],
seedling_boxes_per_tan=entry['seedling_boxes_per_tan'],
installed_seedling_boxes=entry['installed_seedling_boxes'],
seed_grams_per_box=entry['seed_grams_per_box'],
)

View File

@@ -1,7 +1,7 @@
# マスタードキュメント:田植え計画機能
> **作成**: 2026-04-04
> **最終更新**: 2026-04-04
> **最終更新**: 2026-04-05
> **対象機能**: 田植え計画(年度・品種を軸に複数回作成できる苗箱・種もみ使用量計画)
> **実装状況**: MVP実装完了
@@ -10,7 +10,7 @@
## 概要
農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。
各圃場について「反当何枚の苗箱を使うか」「苗箱1枚あたり種もみを何g使うか」を記録し、圃場別・計画全体の苗箱枚数と種もみ使用量を自動集計する。
各圃場について「実際に設置する苗箱枚数」と「苗箱1枚あたり種もみを何g使うか」を記録し、圃場別・計画全体の苗箱枚数と種もみ使用量を自動集計する。
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。
同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
@@ -21,7 +21,7 @@
|---|---|
| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 |
| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 |
| 圃場ごとの苗箱枚数/反の個別調整 | 種もみロット管理 |
| 圃場ごとの設置苗箱枚数の個別調整 | 種もみロット管理 |
| 圃場ごとの種もみg/箱の個別調整 | 在庫の自動引当 |
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 |
@@ -35,23 +35,24 @@
2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する
3. 種もみ在庫は作物単位で管理する
4. 反当苗箱枚数の初期値は品種単位で管理する
5. ただし実際の計画値は圃場単位で上書きでき
6. 種もみg/箱は計画全体のデフォルト値を持ちつつ、圃場単位で上書きでき
7. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化す
8. 同じ年度・同じ品種で複数の計画を作成してよい
9. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
5. 画面上では `反当苗箱枚数 × 面積(反)` を設置苗箱枚数のデフォルト候補として表示す
6. 実際に保存するのは圃場ごとの `設置苗箱枚数` であり、手動調整後の値をそのまま保持す
7. 種もみg/箱は計画全体のデフォルト値を持ちつつ、圃場単位で上書きでき
8. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
9. 同じ年度・同じ品種で複数の計画を作成してよい
10. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
---
## 計算式
### 圃場ごとの苗箱合計
### 圃場ごとの設置苗箱枚数デフォルト候補
`苗箱合計 = 圃場面積(反) × 反当苗箱枚数`
`設置苗箱枚数デフォルト = 圃場面積(反) × 反当苗箱枚数`
### 圃場ごとの種もみ使用量
`種もみkg = 苗箱合計 × 苗箱1枚あたり種もみ(g) ÷ 1000`
`種もみkg = 設置苗箱枚数 × 苗箱1枚あたり種もみ(g) ÷ 1000`
### 計画全体の残在庫見込み
@@ -98,7 +99,7 @@
| 項目 | 型 | 説明 |
|---|---|---|
| field_count | int | 対象圃場数 |
| total_seedling_boxes | decimal | 苗箱枚数合計 |
| total_seedling_boxes | decimal | 設置苗箱枚数合計 |
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
| crop_seed_inventory_kg | decimal | 作物在庫(kg) |
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
@@ -110,7 +111,7 @@
| id | int | PK | |
| plan | FK(RiceTransplantPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| seedling_boxes_per_tan | decimal(6,2) | required | 反当苗箱枚数 |
| installed_seedling_boxes | decimal(8,2) | required | 設置苗箱枚数 |
| seed_grams_per_box | decimal(8,2) | required | 苗箱1枚あたり種もみ(g) |
- `unique_together = ['plan', 'field']`
@@ -122,7 +123,8 @@
|---|---|---|
| field_name | string | 圃場名 |
| field_area_tan | decimal | 圃場面積(反) |
| planned_boxes | decimal | 圃場ごとの苗箱合計 |
| default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 |
| planned_boxes | decimal | 圃場ごとの設置苗箱枚数 |
| planned_seed_kg | decimal | 圃場ごとの種もみkg |
---
@@ -165,8 +167,9 @@
"field": 5,
"field_name": "田中上",
"field_area_tan": "1.2000",
"seedling_boxes_per_tan": "12.00",
"installed_seedling_boxes": "14.40",
"seed_grams_per_box": "200.00",
"default_seedling_boxes": "14.40",
"planned_boxes": "14.40",
"planned_seed_kg": "2.880"
}
@@ -186,12 +189,12 @@ POST/PUT リクエスト例:
"entries": [
{
"field_id": 5,
"seedling_boxes_per_tan": "12.00",
"installed_seedling_boxes": "14.40",
"seed_grams_per_box": "200.00"
},
{
"field_id": 6,
"seedling_boxes_per_tan": "11.50",
"installed_seedling_boxes": "13.80",
"seed_grams_per_box": "190.00"
}
]
@@ -245,12 +248,15 @@ POST/PUT リクエスト例:
- 圃場の追加・除外が可能
- 初期値:
- `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan`
- `設置苗箱枚数デフォルト``反当苗箱枚数 × 面積(反)` で求める
- `種もみg/箱` は計画ヘッダの `default_seed_grams_per_box`
- 圃場テーブル:
- 圃場
- 面積(反)
- 反当苗箱枚数
- 設置苗箱枚数デフォルト(ラベル表示)
- 設置苗箱枚数(入力欄)
- 種もみg/箱
- デフォルト反映ボタン
- 苗箱合計
- 種もみkg
- サマリー:
@@ -278,7 +284,7 @@ POST/PUT リクエスト例:
1. 計画名は必須
2. 品種は必須
3. 圃場は1件以上必要
4. `seedling_boxes_per_tan``seed_grams_per_box` は 0 以上の数値を想定
4. `installed_seedling_boxes``seed_grams_per_box` は 0 以上の数値を想定
5. 在庫不足でも保存は許可し、UIで不足を可視化する
6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要

View File

@@ -9,7 +9,7 @@ import { api } from '@/lib/api';
import { Crop, Field, RiceTransplantPlan } from '@/types';
type EntryInput = {
seedling_boxes_per_tan: string;
installed_seedling_boxes: string;
seed_grams_per_box: string;
};
@@ -47,11 +47,17 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null;
};
const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
const calculateDefaultBoxes = (fieldId: number) => {
const field = allFields.find((item) => item.id === fieldId);
const variety = varietyId ? getVariety(varietyId) : null;
const defaultBoxes = variety?.default_seedling_boxes_per_tan ?? '0';
const areaTan = parseFloat(field?.area_tan ?? '0');
const defaultBoxesPerTan = parseFloat(variety?.default_seedling_boxes_per_tan ?? '0');
return (areaTan * defaultBoxesPerTan).toFixed(2);
};
const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
return {
seedling_boxes_per_tan: String(defaultBoxes),
installed_seedling_boxes: calculateDefaultBoxes(fieldId),
seed_grams_per_box: nextDefaultSeedGramsPerBox,
};
};
@@ -83,7 +89,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
setEntries(
plan.entries.reduce((acc: EntryMap, entry) => {
acc[entry.field] = {
seedling_boxes_per_tan: String(entry.seedling_boxes_per_tan),
installed_seedling_boxes: String(entry.installed_seedling_boxes),
seed_grams_per_box: String(entry.seed_grams_per_box),
};
return acc;
@@ -161,14 +167,18 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
};
const fieldRows = useMemo(
() => selectedFields.map((field) => ({ field, entry: entries[field.id] ?? initializeEntry(field.id) })),
[selectedFields, entries, varietyId, defaultSeedGramsPerBox]
() =>
selectedFields.map((field) => ({
field,
entry: entries[field.id] ?? initializeEntry(field.id),
defaultBoxes: calculateDefaultBoxes(field.id),
})),
[selectedFields, entries, varietyId, defaultSeedGramsPerBox, allFields]
);
const calculateBoxes = (field: Field, entry: EntryInput) => {
const areaTan = parseFloat(field.area_tan || '0');
const boxesPerTan = parseFloat(entry.seedling_boxes_per_tan || '0');
return areaTan * boxesPerTan;
const calculateBoxes = (_field: Field, entry: EntryInput) => {
const installedBoxes = parseFloat(entry.installed_seedling_boxes || '0');
return installedBoxes;
};
const calculateSeedKg = (field: Field, entry: EntryInput) => {
@@ -205,7 +215,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
notes,
entries: selectedFields.map((field) => ({
field_id: field.id,
seedling_boxes_per_tan: entries[field.id]?.seedling_boxes_per_tan ?? initializeEntry(field.id).seedling_boxes_per_tan,
installed_seedling_boxes: entries[field.id]?.installed_seedling_boxes ?? initializeEntry(field.id).installed_seedling_boxes,
seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox,
})),
};
@@ -407,21 +417,34 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
<tr>
<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>
<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">g/</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">kg</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{fieldRows.map(({ field, entry }) => (
{fieldRows.map(({ field, entry, defaultBoxes }) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{field.area_tan}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<span className="text-xs tabular-nums text-gray-500">{defaultBoxes}</span>
<button
type="button"
onClick={() => updateEntry(field.id, 'installed_seedling_boxes', defaultBoxes)}
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
</td>
<td className="px-4 py-3">
<input
value={entry.seedling_boxes_per_tan}
onChange={(e) => updateEntry(field.id, 'seedling_boxes_per_tan', e.target.value)}
value={entry.installed_seedling_boxes}
onChange={(e) => updateEntry(field.id, 'installed_seedling_boxes', e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>

View File

@@ -172,8 +172,9 @@ export interface RiceTransplantEntry {
field: number;
field_name?: string;
field_area_tan?: string;
seedling_boxes_per_tan: string;
installed_seedling_boxes: string;
seed_grams_per_box: string;
default_seedling_boxes: string;
planned_boxes: string;
planned_seed_kg: string;
}