施肥計画の計算設定を保存・復元し、未入力圃場のみ計算オプションを追加

- FertilizationPlanにcalc_settings JSONFieldを追加(migration 0004)
- 編集画面を開くと前回の計算方式・パラメータが復元される
- 「未入力圃場のみ」チェックで既存値を保持したまま新規圃場だけ計算可能

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-03-02 10:50:56 +09:00
parent 21d1dc355d
commit 5145217481
5 changed files with 58 additions and 11 deletions

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0003_distributionplan_distributiongroup_and_more'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='calc_settings',
field=models.JSONField(blank=True, default=list, verbose_name='計算設定'),
),
]

View File

@@ -34,6 +34,7 @@ class FertilizationPlan(models.Model):
'plans.Variety', on_delete=models.PROTECT, 'plans.Variety', on_delete=models.PROTECT,
related_name='fertilization_plans', verbose_name='品種' related_name='fertilization_plans', verbose_name='品種'
) )
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -31,7 +31,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
model = FertilizationPlan model = FertilizationPlan
fields = [ fields = [
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name', 'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at' 'calc_settings', 'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
] ]
def get_variety_name(self, obj): def get_variety_name(self, obj):
@@ -53,7 +53,7 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = FertilizationPlan model = FertilizationPlan
fields = ['id', 'name', 'year', 'variety', 'entries'] fields = ['id', 'name', 'year', 'variety', 'calc_settings', 'entries']
def create(self, validated_data): def create(self, validated_data):
entries_data = validated_data.pop('entries', []) entries_data = validated_data.pop('entries', [])

View File

@@ -91,7 +91,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer))); const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id)); const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
setPlanFertilizers(ferts); setPlanFertilizers(ferts);
setCalcSettings(ferts.map((f: Fertilizer) => ({ fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' }))); // 保存済み計算設定を復元、なければデフォルト値
setCalcSettings(ferts.map((f: Fertilizer) => {
const saved = plan.calc_settings?.find((s) => s.fertilizer_id === f.id);
return saved
? { fertilizer_id: f.id, method: saved.method as CalcMethod, param: saved.param }
: { fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' };
}));
const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field))); const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field)));
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id)); const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
@@ -172,17 +178,29 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; }); setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
}; };
const [calcNewOnly, setCalcNewOnly] = useState(true);
// ─── 自動計算 // ─── 自動計算
const runCalc = async (setting: CalcSetting) => { const runCalc = async (setting: CalcSetting) => {
if (!setting.param) return alert('パラメータを入力してください'); if (!setting.param) return alert('パラメータを入力してください');
if (selectedFields.length === 0) return alert('対象圃場を選択してください'); if (selectedFields.length === 0) return alert('対象圃場を選択してください');
const targetFields = calcNewOnly
? selectedFields.filter((f) => {
const hasAdjusted = adjusted[f.id]?.[setting.fertilizer_id] !== undefined;
const hasCalc = calcMatrix[f.id]?.[setting.fertilizer_id] !== undefined;
return !hasAdjusted && !hasCalc;
})
: selectedFields;
if (targetFields.length === 0) return alert('未入力の圃場がありません。「全圃場」で実行してください。');
try { try {
const res = await api.post('/fertilizer/calculate/', { const res = await api.post('/fertilizer/calculate/', {
method: setting.method, method: setting.method,
param: parseFloat(setting.param), param: parseFloat(setting.param),
fertilizer_id: setting.fertilizer_id, fertilizer_id: setting.fertilizer_id,
field_ids: selectedFields.map((f) => f.id), field_ids: targetFields.map((f) => f.id),
}); });
const results: { field_id: number; bags: number }[] = res.data; const results: { field_id: number; bags: number }[] = res.data;
@@ -311,7 +329,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setSaving(true); setSaving(true);
try { try {
const payload = { name, year, variety: varietyId, entries }; const payload = { name, year, variety: varietyId, calc_settings: calcSettings, entries };
if (isNew) { if (isNew) {
await api.post('/fertilizer/plans/', payload); await api.post('/fertilizer/plans/', payload);
} else { } else {
@@ -481,12 +499,23 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<div className="bg-white rounded-lg shadow p-4 mb-4"> <div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700"></h2> <h2 className="text-sm font-semibold text-gray-700"></h2>
<button <div className="flex items-center gap-3">
onClick={() => setShowFertPicker(true)} <label className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer select-none">
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1" <input
> type="checkbox"
<Plus className="h-3 w-3" /> checked={calcNewOnly}
</button> onChange={(e) => setCalcNewOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<button
onClick={() => setShowFertPicker(true)}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
>
<Plus className="h-3 w-3" />
</button>
</div>
</div> </div>
{planFertilizers.length === 0 ? ( {planFertilizers.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400"></p>

View File

@@ -84,6 +84,7 @@ export interface FertilizationPlan {
variety: number; variety: number;
variety_name: string; variety_name: string;
crop_name: string; crop_name: string;
calc_settings: { fertilizer_id: number; method: string; param: string }[];
entries: FertilizationEntry[]; entries: FertilizationEntry[];
field_count: number; field_count: number;
fertilizer_count: number; fertilizer_count: number;