diff --git a/backend/apps/materials/migrations/0005_material_seed_type.py b/backend/apps/materials/migrations/0005_material_seed_type.py new file mode 100644 index 0000000..73e06ba --- /dev/null +++ b/backend/apps/materials/migrations/0005_material_seed_type.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('materials', '0004_fix_spreading_item_on_delete'), + ] + + operations = [ + migrations.AlterField( + model_name='material', + name='material_type', + field=models.CharField( + choices=[ + ('fertilizer', '肥料'), + ('pesticide', '農薬'), + ('seed', '種子'), + ('seedling', '種苗'), + ('other', 'その他'), + ], + max_length=20, + verbose_name='資材種別', + ), + ), + ] diff --git a/backend/apps/materials/models.py b/backend/apps/materials/models.py index 9e9ca9c..315b094 100644 --- a/backend/apps/materials/models.py +++ b/backend/apps/materials/models.py @@ -10,6 +10,7 @@ class Material(models.Model): class MaterialType(models.TextChoices): FERTILIZER = 'fertilizer', '肥料' PESTICIDE = 'pesticide', '農薬' + SEED = 'seed', '種子' SEEDLING = 'seedling', '種苗' OTHER = 'other', 'その他' diff --git a/backend/apps/materials/serializers.py b/backend/apps/materials/serializers.py index 9132456..6b0cf7a 100644 --- a/backend/apps/materials/serializers.py +++ b/backend/apps/materials/serializers.py @@ -112,11 +112,15 @@ class MaterialWriteSerializer(serializers.ModelSerializer): {'fertilizer_profile': '農薬には肥料詳細を設定できません。'} ) if ( - material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER} + material_type in { + Material.MaterialType.SEED, + Material.MaterialType.SEEDLING, + Material.MaterialType.OTHER, + } and (fertilizer_profile or pesticide_profile) ): raise serializers.ValidationError( - '種苗・その他には詳細プロファイルを設定できません。' + '種子・種苗・その他には詳細プロファイルを設定できません。' ) return attrs diff --git a/backend/apps/plans/migrations/0008_variety_seed_material.py b/backend/apps/plans/migrations/0008_variety_seed_material.py new file mode 100644 index 0000000..69df6d7 --- /dev/null +++ b/backend/apps/plans/migrations/0008_variety_seed_material.py @@ -0,0 +1,26 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('materials', '0005_material_seed_type'), + ('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'), + ] + + operations = [ + migrations.AddField( + model_name='variety', + name='seed_material', + field=models.ForeignKey( + blank=True, + limit_choices_to={'material_type': 'seed'}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='varieties', + to='materials.material', + verbose_name='種子在庫資材', + ), + ), + ] diff --git a/backend/apps/plans/models.py b/backend/apps/plans/models.py index 00708aa..a19c4b0 100644 --- a/backend/apps/plans/models.py +++ b/backend/apps/plans/models.py @@ -29,6 +29,15 @@ class Variety(models.Model): default=0, verbose_name="反当苗箱枚数デフォルト", ) + seed_material = models.ForeignKey( + 'materials.Material', + on_delete=models.SET_NULL, + related_name='varieties', + verbose_name='種子在庫資材', + blank=True, + null=True, + limit_choices_to={'material_type': 'seed'}, + ) class Meta: verbose_name = "品種マスタ" diff --git a/backend/apps/plans/serializers.py b/backend/apps/plans/serializers.py index 5771aa9..b79f7f5 100644 --- a/backend/apps/plans/serializers.py +++ b/backend/apps/plans/serializers.py @@ -2,14 +2,24 @@ from decimal import Decimal from rest_framework import serializers from apps.fields.models import Field +from apps.materials.models import StockTransaction from .models import Crop, Variety, Plan from .models import RiceTransplantEntry, RiceTransplantPlan class VarietySerializer(serializers.ModelSerializer): + seed_material_name = serializers.CharField(source='seed_material.name', read_only=True) + class Meta: model = Variety - fields = '__all__' + fields = [ + 'id', + 'crop', + 'name', + 'default_seedling_boxes_per_tan', + 'seed_material', + 'seed_material_name', + ] class CropSerializer(serializers.ModelSerializer): @@ -50,7 +60,6 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer): ) planned_boxes = serializers.SerializerMethodField() default_seedling_boxes = serializers.SerializerMethodField() - planned_seed_kg = serializers.SerializerMethodField() class Meta: model = RiceTransplantEntry @@ -62,7 +71,6 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer): 'installed_seedling_boxes', 'default_seedling_boxes', 'planned_boxes', - 'planned_seed_kg', ] def get_default_seedling_boxes(self, obj): @@ -73,21 +81,15 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer): def get_planned_boxes(self, obj): return str(obj.installed_seedling_boxes.quantize(Decimal('0.01'))) - def get_planned_seed_kg(self, obj): - seed_kg = ( - obj.installed_seedling_boxes * obj.plan.default_seed_grams_per_box / Decimal('1000') - ).quantize(Decimal('0.001')) - return str(seed_kg) - - class RiceTransplantPlanSerializer(serializers.ModelSerializer): variety_name = serializers.CharField(source='variety.name', read_only=True) crop_name = serializers.CharField(source='variety.crop.name', read_only=True) + seed_material_name = serializers.CharField(source='variety.seed_material.name', read_only=True) entries = RiceTransplantEntrySerializer(many=True, read_only=True) field_count = serializers.SerializerMethodField() total_seedling_boxes = serializers.SerializerMethodField() total_seed_kg = serializers.SerializerMethodField() - crop_seed_inventory_kg = serializers.SerializerMethodField() + variety_seed_inventory_kg = serializers.SerializerMethodField() remaining_seed_kg = serializers.SerializerMethodField() class Meta: @@ -102,11 +104,12 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer): 'default_seed_grams_per_box', 'seedling_boxes_per_tan', 'notes', + 'seed_material_name', 'entries', 'field_count', 'total_seedling_boxes', 'total_seed_kg', - 'crop_seed_inventory_kg', + 'variety_seed_inventory_kg', 'remaining_seed_kg', 'created_at', 'updated_at', @@ -117,28 +120,58 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer): def get_total_seedling_boxes(self, obj): total = sum( - entry.installed_seedling_boxes - for entry in obj.entries.all() + ( + entry.installed_seedling_boxes + for entry in obj.entries.all() + ), + Decimal('0'), ) return str(total.quantize(Decimal('0.01'))) def get_total_seed_kg(self, obj): total = sum( ( - entry.installed_seedling_boxes - * obj.default_seed_grams_per_box - / Decimal('1000') - ) - for entry in obj.entries.all() + ( + entry.installed_seedling_boxes + * obj.default_seed_grams_per_box + / Decimal('1000') + ) + for entry in obj.entries.all() + ), + Decimal('0'), ) return str(total.quantize(Decimal('0.001'))) - def get_crop_seed_inventory_kg(self, obj): - return str(obj.variety.crop.seed_inventory_kg) + def get_variety_seed_inventory_kg(self, obj): + return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001'))) def get_remaining_seed_kg(self, obj): total_seed = Decimal(self.get_total_seed_kg(obj)) - return str((obj.variety.crop.seed_inventory_kg - total_seed).quantize(Decimal('0.001'))) + return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001'))) + + def _get_seed_inventory_kg(self, obj): + material = obj.variety.seed_material + if material is None: + return Decimal('0') + + transactions = list(material.stock_transactions.all()) + increase = sum( + ( + txn.quantity + for txn in transactions + if txn.transaction_type in StockTransaction.INCREASE_TYPES + ), + Decimal('0'), + ) + decrease = sum( + ( + txn.quantity + for txn in transactions + if txn.transaction_type in StockTransaction.DECREASE_TYPES + ), + Decimal('0'), + ) + return increase - decrease class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer): diff --git a/backend/apps/plans/views.py b/backend/apps/plans/views.py index 6e5f897..76d192d 100644 --- a/backend/apps/plans/views.py +++ b/backend/apps/plans/views.py @@ -19,7 +19,7 @@ class CropViewSet(viewsets.ModelViewSet): class VarietyViewSet(viewsets.ModelViewSet): - queryset = Variety.objects.all() + queryset = Variety.objects.select_related('seed_material', 'crop').all() serializer_class = VarietySerializer @@ -134,7 +134,7 @@ class PlanViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def get_crops_with_varieties(self, request): - crops = Crop.objects.prefetch_related('varieties').all() + crops = Crop.objects.prefetch_related('varieties__seed_material').all() return Response(CropSerializer(crops, many=True).data) @@ -142,7 +142,12 @@ class RiceTransplantPlanViewSet(viewsets.ModelViewSet): queryset = RiceTransplantPlan.objects.select_related( 'variety', 'variety__crop', - ).prefetch_related('entries', 'entries__field') + 'variety__seed_material', + ).prefetch_related( + 'variety__seed_material__stock_transactions', + 'entries', + 'entries__field', + ) def get_queryset(self): queryset = self.queryset diff --git a/document/16_マスタードキュメント_田植え計画編.md b/document/16_マスタードキュメント_田植え計画編.md index 1edf0b7..52f4779 100644 --- a/document/16_マスタードキュメント_田植え計画編.md +++ b/document/16_マスタードキュメント_田植え計画編.md @@ -12,7 +12,7 @@ 農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。 各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。 -圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。 +圃場候補は既存の作付け計画から自動取得し、種もみ在庫は品種単位、反当苗箱枚数の初期値も品種単位で管理する。 同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。 ### 機能スコープ(IN / OUT) @@ -24,7 +24,7 @@ | 圃場ごとの苗箱数の個別調整 | 種もみロット管理 | | 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 | | 苗箱合計・種もみkg合計の自動集計 | PDF出力 | -| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 | +| 品種ごとの種もみ在庫参照 | 品種ごとの播種日管理 | | 品種ごとの反当苗箱枚数デフォルト管理 | | --- @@ -33,7 +33,7 @@ 1. 田植え計画は `年度 × 品種` を軸に作成する 2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する -3. 種もみ在庫は作物単位で管理する +3. 種もみ在庫は品種単位で管理する 4. 反当苗箱枚数の初期値は品種単位で管理する 5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う 6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する @@ -57,27 +57,20 @@ ### 計画全体の残在庫見込み -`残在庫見込み = 作物の種もみ在庫(kg) - 計画全体の種もみkg合計` +`残在庫見込み = 品種の種もみ在庫(kg) - 計画全体の種もみkg合計` --- ## データモデル -### Crop(作物マスタ) - -既存 `plans.Crop` に以下を追加。 - -| フィールド | 型 | 制約 | 説明 | -|---|---|---|---| -| seed_inventory_kg | decimal(10,3) | default=0 | 作物単位の種もみ在庫(kg) | - ### Variety(品種マスタ) -既存 `plans.Variety` に以下を追加。 +既存 `plans.Variety` に以下を追加・参照する。 | フィールド | 型 | 制約 | 説明 | |---|---|---|---| | default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 | +| seed_material | FK(materials.Material) 相当 | nullable | その品種に対応する種子在庫 | ### RiceTransplantPlan(田植え計画) @@ -103,7 +96,7 @@ | field_count | int | 対象圃場数 | | total_seedling_boxes | decimal | 苗箱数合計 | | total_seed_kg | decimal | 種もみ使用量合計(kg) | -| crop_seed_inventory_kg | decimal | 作物在庫(kg) | +| variety_seed_inventory_kg | decimal | 品種在庫(kg) | | remaining_seed_kg | decimal | 残在庫見込み(kg) | ### RiceTransplantEntry(田植え計画エントリ) @@ -126,7 +119,6 @@ | field_area_tan | decimal | 圃場面積(反) | | default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 | | planned_boxes | decimal | 圃場ごとの苗箱数 | -| planned_seed_kg | decimal | 圃場ごとの必要種もみkg | --- @@ -157,11 +149,12 @@ "crop_name": "水稲", "seedling_boxes_per_tan": "12.00", "default_seed_grams_per_box": "200.00", + "seed_material_name": "にこまる 種もみ", "notes": "", "field_count": 8, "total_seedling_boxes": "98.40", "total_seed_kg": "19.680", - "crop_seed_inventory_kg": "25.000", + "variety_seed_inventory_kg": "25.000", "remaining_seed_kg": "5.320", "entries": [ { @@ -171,8 +164,7 @@ "field_area_tan": "1.2000", "installed_seedling_boxes": "14.40", "default_seedling_boxes": "14.40", - "planned_boxes": "14.40", - "planned_seed_kg": "2.880" + "planned_boxes": "14.40" } ] } @@ -203,14 +195,15 @@ POST/PUT リクエスト例: 更新時は `entries` を全置換する。 -### 作物・品種マスタ更新 +### 品種マスタ更新 / 在庫管理 田植え計画に必要な既定値は既存 API で更新する。 | メソッド | URL | 更新項目 | |---|---|---| -| PATCH | `/api/plans/crops/{id}/` | `seed_inventory_kg` | | PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` | +| PATCH | `/api/plans/varieties/{id}/` | `seed_material` または同等の種子在庫参照 | +| CRUD | `/api/materials/materials/?material_type=seed` | 品種別の種子在庫マスタ | --- @@ -255,26 +248,26 @@ POST/PUT リクエスト例: - 面積(反) - 苗箱数入力欄 - 左側にデフォルト苗箱数ラベルを表示 - - 必要種もみkg + - 小数は 1 桁表示を基本とする - 列操作: - `反当苗箱枚数` の入力欄 - デフォルトを列単位で一括反映するボタン - 列単位の四捨五入ボタン + - 施肥計画の四捨五入ボタンと同じ配置・2ステート動作 - サマリー: - 対象圃場数 - 苗箱合計 - 種もみ計画kg - - 作物在庫kg + - 品種在庫kg - 残在庫見込みkg ### 3. 品種管理モーダル `/allocation` 既存の作付け計画画面内の品種管理モーダルを拡張。 -- 作物単位: - - 種もみ在庫(kg) を更新可能 - 品種単位: - 反当苗箱枚数デフォルトを更新可能 + - 対応する種子在庫を設定可能 --- @@ -291,10 +284,8 @@ POST/PUT リクエスト例: ## 既知の制約 -1. 種もみ在庫は作物単位のみで、品種別在庫には未対応 -2. 田植え計画の PDF 出力は未実装 -3. 在庫管理 `materials` app とは未連携で、引当・使用実績は持たない -4. 実播種や田植え実績との連携は未実装 +1. 田植え計画の PDF 出力は未実装 +2. 実播種や田植え実績との連携は未実装 --- @@ -303,11 +294,15 @@ POST/PUT リクエスト例: | 種別 | パス | |---|---| | モデル | `backend/apps/plans/models.py` | +| モデル | `backend/apps/materials/models.py` | | シリアライザ | `backend/apps/plans/serializers.py` | +| シリアライザ | `backend/apps/materials/serializers.py` | | ViewSet | `backend/apps/plans/views.py` | | URL | `backend/apps/plans/urls.py` | -| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py` | +| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py`, `backend/apps/plans/migrations/0008_variety_seed_material.py`, `backend/apps/materials/migrations/0005_material_seed_type.py` | | 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` | | 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` | | ナビゲーション | `frontend/src/components/Navbar.tsx` | | 品種管理モーダル | `frontend/src/app/allocation/page.tsx` | +| 在庫画面 | `frontend/src/app/materials/page.tsx` | +| 資材マスタ | `frontend/src/app/materials/masters/page.tsx` | diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index 1d59cfe..5d5f216 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { api } from '@/lib/api'; -import { Field, Crop, Plan } from '@/types'; +import { Field, Crop, Material, Plan } from '@/types'; import Navbar from '@/components/Navbar'; import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react'; @@ -23,6 +23,7 @@ export default function AllocationPage() { const [fields, setFields] = useState([]); const [crops, setCrops] = useState([]); const [plans, setPlans] = useState([]); + const [seedMaterials, setSeedMaterials] = useState([]); const [year, setYear] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('allocationYear'); @@ -60,14 +61,16 @@ export default function AllocationPage() { const fetchData = async (background = false) => { if (!background) setLoading(true); try { - const [fieldsRes, cropsRes, plansRes] = await Promise.all([ + const [fieldsRes, cropsRes, plansRes, seedMaterialsRes] = await Promise.all([ api.get('/fields/?ordering=group_name,display_order,id'), api.get('/plans/crops/'), api.get(`/plans/?year=${year}`), + api.get('/materials/materials/?material_type=seed'), ]); setFields(fieldsRes.data); setCrops(cropsRes.data); setPlans(plansRes.data); + setSeedMaterials(seedMaterialsRes.data); } catch (error) { console.error('Failed to fetch data:', error); } finally { @@ -367,20 +370,6 @@ export default function AllocationPage() { } }; - const handleUpdateCropSeedInventory = async (cropId: number, seedInventoryKg: string) => { - try { - const crop = crops.find((item) => item.id === cropId); - if (!crop) return; - await api.patch(`/plans/crops/${cropId}/`, { - seed_inventory_kg: seedInventoryKg, - }); - await fetchData(true); - } catch (error) { - console.error('Failed to update crop seed inventory:', error); - alert('種もみ在庫の更新に失敗しました'); - } - }; - const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => { try { const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId); @@ -395,6 +384,20 @@ export default function AllocationPage() { } }; + const handleUpdateVarietySeedMaterial = async (varietyId: number, seedMaterialId: string) => { + try { + const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId); + if (!variety) return; + await api.patch(`/plans/varieties/${varietyId}/`, { + seed_material: seedMaterialId ? parseInt(seedMaterialId, 10) : null, + }); + await fetchData(true); + } catch (error) { + console.error('Failed to update variety seed material:', error); + alert('種子在庫の紐付け更新に失敗しました'); + } + }; + const toggleFieldSelection = (fieldId: number) => { setSelectedFields((prev) => { const next = new Set(prev); @@ -1057,15 +1060,6 @@ export default function AllocationPage() {
- {managerCropId && ( -
-

田植え計画用設定

- crop.id === managerCropId) || null} - onSave={handleUpdateCropSeedInventory} - /> -
- )} {managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
    {getVarietiesForCrop(managerCropId).map((v) => ( @@ -1085,6 +1079,15 @@ export default function AllocationPage() { initialValue={v.default_seedling_boxes_per_tan} onSave={handleUpdateVarietyDefaultBoxes} /> +
    + +
    ))}
@@ -1150,49 +1153,6 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name ); } -function CropSeedInventoryForm({ - crop, - onSave, -}: { - crop: Crop | null; - onSave: (cropId: number, seedInventoryKg: string) => Promise; -}) { - const [value, setValue] = useState(crop?.seed_inventory_kg ?? '0'); - const [saving, setSaving] = useState(false); - - useEffect(() => { - setValue(crop?.seed_inventory_kg ?? '0'); - }, [crop?.id, crop?.seed_inventory_kg]); - - const handleSave = async () => { - if (!crop) return; - setSaving(true); - await onSave(crop.id, value); - setSaving(false); - }; - - return ( -
-
- - setValue(e.target.value)} - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" - inputMode="decimal" - /> -
- -
- ); -} - function VarietyDefaultBoxesForm({ varietyId, initialValue, @@ -1236,3 +1196,60 @@ function VarietyDefaultBoxesForm({
); } + +function VarietySeedMaterialForm({ + varietyId, + initialValue, + initialLabel, + materials, + onSave, +}: { + varietyId: number; + initialValue: string; + initialLabel: string | null; + materials: Material[]; + onSave: (varietyId: number, seedMaterialId: string) => Promise; +}) { + const [value, setValue] = useState(initialValue); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const handleSave = async () => { + setSaving(true); + await onSave(varietyId, value); + setSaving(false); + }; + + return ( +
+
+ + +

+ 現在: {initialLabel || '未設定'} +

+
+ +
+ ); +} diff --git a/frontend/src/app/materials/_components/MaterialForm.tsx b/frontend/src/app/materials/_components/MaterialForm.tsx index 1398010..12c4fdb 100644 --- a/frontend/src/app/materials/_components/MaterialForm.tsx +++ b/frontend/src/app/materials/_components/MaterialForm.tsx @@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react'; import { Material } from '@/types'; -export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc'; +export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc'; export interface MaterialFormState { name: string; @@ -244,14 +244,20 @@ export default function MaterialForm({ /> - + {tab === 'seed' ? ( +
+ 種子 +
+ ) : ( + + )} ({ name: '', material_type: - tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other', + tab === 'fertilizer' + ? 'fertilizer' + : tab === 'pesticide' + ? 'pesticide' + : tab === 'seed' + ? 'seed' + : 'other', maker: '', - stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece', + stock_unit: + tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : tab === 'seed' ? 'kg' : 'piece', is_active: true, notes: '', fertilizer_profile: { @@ -334,6 +342,21 @@ export default function MaterialMastersPage() { onCancel={cancelEdit} /> )} + {tab === 'seed' && ( + + )} {tab === 'misc' && ( + + + 資材名 + 種別 + メーカー + 単位 + 備考 + 使用中 + 操作 + + + + {props.editingId === 'new' && } + {props.materials.map((material) => + props.editingId === material.id ? ( + + ) : ( + + {material.name} + {material.material_type_display} + {material.maker || '-'} + {material.stock_unit_display} + {material.notes || '-'} + + {material.is_active ? '○' : '-'} + + + props.onEdit(material)} + onDelete={() => props.onDelete(material)} + /> + + + ) + )} + {props.materials.length === 0 && props.editingId === null && ( + + + 該当する資材が登録されていません + + + )} + + + ); +} + function RowActions({ disabled, onEdit, diff --git a/frontend/src/app/materials/page.tsx b/frontend/src/app/materials/page.tsx index 0cabfe1..f9bbb0f 100644 --- a/frontend/src/app/materials/page.tsx +++ b/frontend/src/app/materials/page.tsx @@ -10,12 +10,13 @@ import Navbar from '@/components/Navbar'; import { api } from '@/lib/api'; import { Material, StockSummary, StockTransaction } from '@/types'; -type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc'; +type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'seed' | 'misc'; const tabs: { key: FilterTab; label: string }[] = [ { key: 'all', label: '全て' }, { key: 'fertilizer', label: '肥料' }, { key: 'pesticide', label: '農薬' }, + { key: 'seed', label: '種子' }, { key: 'misc', label: 'その他' }, ]; diff --git a/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx b/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx index 738974a..b2d489b 100644 --- a/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx +++ b/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx @@ -6,7 +6,7 @@ import { ChevronLeft, Save } from 'lucide-react'; import Navbar from '@/components/Navbar'; import { api } from '@/lib/api'; -import { Crop, Field, RiceTransplantPlan } from '@/types'; +import { Crop, Field, RiceTransplantPlan, StockSummary } from '@/types'; type BoxMap = Record; @@ -27,10 +27,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) const [allFields, setAllFields] = useState([]); const [candidateFields, setCandidateFields] = useState([]); const [selectedFields, setSelectedFields] = useState([]); + const [seedStocks, setSeedStocks] = useState([]); const [calcBoxes, setCalcBoxes] = useState({}); const [adjustedBoxes, setAdjustedBoxes] = useState({}); - const [applyToEmptyOnly, setApplyToEmptyOnly] = useState(true); + const [boxesRounded, setBoxesRounded] = useState(false); const [loading, setLoading] = useState(!isNew); const [saving, setSaving] = useState(false); @@ -41,27 +42,24 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) const getVariety = (id: number) => crops.flatMap((crop) => crop.varieties).find((variety) => variety.id === id); - const getSelectedCrop = () => { - if (!varietyId) return null; - return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null; - }; - const calculateDefaultBoxes = (field: Field, perTan: string) => { const areaTan = parseFloat(field.area_tan || '0'); const boxesPerTan = parseFloat(perTan || '0'); - return isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(2); + return Number.isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(1); }; useEffect(() => { const init = async () => { setError(null); try { - const [cropsRes, fieldsRes] = await Promise.all([ + const [cropsRes, fieldsRes, seedStockRes] = await Promise.all([ api.get('/plans/crops/'), api.get('/fields/?ordering=display_order,id'), + api.get('/materials/stock-summary/?material_type=seed'), ]); setCrops(cropsRes.data); setAllFields(fieldsRes.data); + setSeedStocks(seedStockRes.data); if (!isNew && planId) { const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`); @@ -81,8 +79,8 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) const nextAdjusted: BoxMap = {}; const nextCalc: BoxMap = {}; plan.entries.forEach((entry) => { - nextAdjusted[entry.field] = String(entry.installed_seedling_boxes); - nextCalc[entry.field] = String(entry.default_seedling_boxes); + nextAdjusted[entry.field] = Number(entry.installed_seedling_boxes).toFixed(1); + nextCalc[entry.field] = Number(entry.default_seedling_boxes).toFixed(1); }); setAdjustedBoxes(nextAdjusted); setCalcBoxes(nextCalc); @@ -101,7 +99,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) const fetchCandidates = async () => { if (!varietyId || !year || (!isNew && loading)) return; try { - const res = await api.get(`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`); + const res = await api.get( + `/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}` + ); const nextCandidates: Field[] = res.data; setCandidateFields(nextCandidates); if (isNew) { @@ -122,7 +122,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) if (isNew || seedlingBoxesPerTan === '') { setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan); } - }, [varietyId, crops, isNew]); + }, [varietyId, crops, isNew, seedlingBoxesPerTan]); useEffect(() => { const nextCalc: BoxMap = {}; @@ -130,6 +130,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan); }); setCalcBoxes(nextCalc); + setBoxesRounded(false); }, [selectedFields, seedlingBoxesPerTan]); const addField = (field: Field) => { @@ -162,39 +163,59 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) setAdjustedBoxes((prev) => { const next = { ...prev }; selectedFields.forEach((field) => { - const currentValue = prev[field.id] ?? ''; - if (applyToEmptyOnly && currentValue !== '') return; next[field.id] = calcBoxes[field.id] ?? ''; }); return next; }); + setBoxesRounded(false); }; - const roundColumn = () => { + const toggleRoundColumn = () => { + if (boxesRounded) { + setAdjustedBoxes((prev) => { + const next = { ...prev }; + selectedFields.forEach((field) => { + delete next[field.id]; + }); + return next; + }); + setBoxesRounded(false); + return; + } + setAdjustedBoxes((prev) => { const next = { ...prev }; selectedFields.forEach((field) => { - const raw = prev[field.id] !== undefined && prev[field.id] !== '' ? prev[field.id] : calcBoxes[field.id]; + const raw = calcBoxes[field.id] ?? prev[field.id]; if (!raw) return; const value = parseFloat(raw); - if (isNaN(value)) return; + if (Number.isNaN(value)) return; next[field.id] = String(Math.round(value)); }); return next; }); + setBoxesRounded(true); }; const effectiveBoxes = (fieldId: number) => { - const raw = adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' ? adjustedBoxes[fieldId] : calcBoxes[fieldId]; + const raw = + adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' + ? adjustedBoxes[fieldId] + : calcBoxes[fieldId]; const value = parseFloat(raw ?? '0'); - return isNaN(value) ? 0 : value; + 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 + : 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 cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0'); - const remainingSeedKg = cropSeedInventoryKg - totalSeedKg; + const seedInventoryKg = parseFloat(seedStock?.current_stock ?? '0'); + const remainingSeedKg = seedInventoryKg - totalSeedKg; const handleSave = async () => { setError(null); @@ -251,7 +272,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) selectedFields.map((field) => ({ field, defaultBoxes: calcBoxes[field.id] ?? '', - boxCount: adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' ? adjustedBoxes[field.id] : calcBoxes[field.id] ?? '', + boxCount: + adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' + ? adjustedBoxes[field.id] + : calcBoxes[field.id] ?? '', })), [selectedFields, calcBoxes, adjustedBoxes] ); @@ -271,7 +295,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
-

@@ -301,9 +328,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) value={name} onChange={(e) => setName(e.target.value)} 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" - placeholder="例: 2026年度 コシヒカリ 第1回" + placeholder="例: 2026年度 にこまる 第1回" /> -

同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。

+

+ 同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。 +

@@ -339,7 +368,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
- + setDefaultSeedGramsPerBox(e.target.value)} @@ -352,17 +383,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })

対象圃場

-
- -
@@ -375,7 +395,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) {field.name} × ))} - {selectedFields.length === 0 &&

圃場が選択されていません。

} + {selectedFields.length === 0 && ( +

圃場が選択されていません。

+ )}
{unselectedFields.length > 0 && ( @@ -396,35 +418,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) )}
-
-
-
- - setSeedlingBoxesPerTan(e.target.value)} - className="w-28 rounded-lg border border-gray-300 px-3 py-2 text-sm text-right focus:outline-none focus:ring-2 focus:ring-green-500" - inputMode="decimal" - /> -
- - -
-

各圃場のデフォルト苗箱数は `反当苗箱枚数 × 面積(反)` で計算されます。

-
-
@@ -445,17 +438,21 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
苗箱合計 - {totalBoxes.toFixed(2)}枚 + {totalBoxes.toFixed(1)}枚
必要種もみ量 {totalSeedKg.toFixed(3)}kg
- {getSelectedCrop()?.name ?? '作物'} 在庫 - {cropSeedInventoryKg.toFixed(3)}kg + {seedStock?.name || selectedVariety?.seed_material_name || '種子在庫未設定'} + {seedInventoryKg.toFixed(3)}kg
-
+
残在庫見込み {remainingSeedKg.toFixed(3)}kg
@@ -469,32 +466,74 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number }) 圃場 面積(反) - 苗箱数 - 種もみkg + +
苗箱数
+
+
反当 {seedlingBoxesPerTan || '0'}枚
+
合計 {totalBoxes.toFixed(1)}枚
+
+ + (枚) + + + + + + + 反当苗箱枚数 + + + +
+ setSeedlingBoxesPerTan(e.target.value)} + className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:outline-none focus:ring-1 focus:ring-green-400" + inputMode="decimal" + /> + +
+ - {fieldRows.map(({ field, defaultBoxes, boxCount }) => { - const seedKg = (effectiveBoxes(field.id) * seedGrams) / 1000; - return ( - - {field.name} - {field.area_tan} - -
- 既定 {defaultBoxes || '0.00'}枚 - updateBoxCount(field.id, 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" - /> -
- - {seedKg.toFixed(3)} - - ); - })} + {fieldRows.map(({ field, defaultBoxes, boxCount }) => ( + + {field.name} + + {field.area_tan} + + +
+ + 既定 {defaultBoxes || '0.0'}枚 + + updateBoxCount(field.id, 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" + /> +
+ + + ))}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 771cbe8..848cd7e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -37,12 +37,13 @@ export interface Variety { crop: number; name: string; default_seedling_boxes_per_tan: string; + seed_material: number | null; + seed_material_name: string | null; } export interface Crop { id: number; name: string; - seed_inventory_kg: string; varieties: Variety[]; } @@ -90,7 +91,7 @@ export interface PesticideProfile { export interface Material { id: number; name: string; - material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other'; + material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other'; material_type_display: string; maker: string; stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece'; @@ -123,7 +124,7 @@ export interface StockTransaction { export interface StockSummary { material_id: number; name: string; - material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other'; + material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other'; material_type_display: string; maker: string; stock_unit: string; @@ -175,7 +176,6 @@ export interface RiceTransplantEntry { installed_seedling_boxes: string; default_seedling_boxes: string; planned_boxes: string; - planned_seed_kg: string; } export interface RiceTransplantPlan { @@ -188,11 +188,12 @@ export interface RiceTransplantPlan { default_seed_grams_per_box: string; seedling_boxes_per_tan: string; notes: string; + seed_material_name: string | null; entries: RiceTransplantEntry[]; field_count: number; total_seedling_boxes: string; total_seed_kg: string; - crop_seed_inventory_kg: string; + variety_seed_inventory_kg: string; remaining_seed_kg: string; created_at: string; updated_at: string;