# CODEX 実装指示書: 在庫管理機能(Phase 1) > 作成日: 2026-03-14 > 対象: `keinasystem_t02` > 設計案: `改善案/在庫管理機能実装案.md`(セクション1〜16が対象。17〜21は将来フェーズ) --- ## 0. 実装の前提と絶対ルール ### プロジェクト構造 ``` keinasystem_t02/ ├── backend/ │ ├── keinasystem/ │ │ ├── settings.py # INSTALLED_APPS にアプリ登録 │ │ └── urls.py # ルートURL登録 │ └── apps/ │ ├── fields/ # 圃場管理(Field モデル) │ ├── plans/ # 作付け計画(Crop, Variety モデル) │ ├── fertilizer/ # 施肥計画(Fertilizer, FertilizationPlan 等) │ └── materials/ # ← 新規作成 └── frontend/ └── src/ ├── types/index.ts # 型定義(ここに追記) ├── lib/api.ts # axios インスタンス(変更不要) ├── components/ │ └── Navbar.tsx # ナビゲーション(メニュー追加) └── app/ └── materials/ # ← 新規作成 ``` ### 技術スタック - Backend: Django 5.2 + Django REST Framework + PostgreSQL 16 - Frontend: Next.js 14 (App Router) + TypeScript strict + Tailwind CSS - Docker: `docker compose exec backend python manage.py ...` ### 絶対ルール 1. **既存の `apps/fertilizer/` のモデル・API・画面を壊さない** 2. **`Fertilizer` モデルは改名・削除しない**(本番稼働中) 3. **`FertilizationEntry → Fertilizer` の FK は変更しない** 4. **マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない** 5. **フロントエンドでは `alert()` / `confirm()` を使わない**(インラインバナーで表示) 6. **API エンドポイントは複数形** (`/api/materials/`, NOT `/api/material/`) 7. **Django のベストプラクティスに従う**(ViewSet, Serializer, Router パターン) 8. **TypeScript strict mode に従う** 9. **Next.js 14 では `params` は通常のオブジェクト**(`use(params)` は使わない) --- ## 1. 実装スコープ(Phase 1 のみ) ### やること 1. `apps/materials` Django アプリを新規作成 2. `Material` モデル(共通資材マスタ) 3. `FertilizerProfile` モデル(肥料詳細、Material と 1:1) 4. `PesticideProfile` モデル(農薬詳細、Material と 1:1) 5. `StockTransaction` モデル(入出庫履歴) 6. 既存 `Fertilizer` に `material` OneToOneField を追加(データ移行込み) 7. 資材マスタ CRUD API 8. 在庫履歴 API 9. 在庫集計 API 10. フロントエンド: 在庫一覧画面 11. フロントエンド: 入出庫登録画面 12. フロントエンド: 資材マスタ管理画面 13. Navbar にメニュー追加 ### やらないこと(将来フェーズ) - 公式データ同期(FAMIC、農水省) - 別名辞書(MaterialAlias) - LLM 調査支援 - 施肥計画画面への在庫参照表示 - 在庫自動減算(施肥計画確定時) - `MaterialStockSnapshot`(パフォーマンス最適化) --- ## 2. バックエンド実装 ### 2.1 アプリ作成 ```bash cd backend python manage.py startapp materials mv materials apps/materials ``` `apps/materials/apps.py`: ```python from django.apps import AppConfig class MaterialsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.materials' verbose_name = '資材管理' ``` `backend/keinasystem/settings.py` に追加: ```python INSTALLED_APPS = [ # ... 既存 ... 'apps.materials', # ← 追加 ] ``` `backend/keinasystem/urls.py` に追加: ```python urlpatterns = [ # ... 既存 ... path('api/materials/', include('apps.materials.urls')), # ← 追加 ] ``` ### 2.2 モデル定義 (`apps/materials/models.py`) ```python from django.conf import settings from django.db import models class Material(models.Model): """共通資材マスタ""" class MaterialType(models.TextChoices): FERTILIZER = 'fertilizer', '肥料' PESTICIDE = 'pesticide', '農薬' SEEDLING = 'seedling', '種苗' OTHER = 'other', 'その他' class StockUnit(models.TextChoices): BAG = 'bag', '袋' BOTTLE = 'bottle', '本' KG = 'kg', 'kg' LITER = 'liter', 'L' PIECE = 'piece', '個' name = models.CharField(max_length=100, verbose_name='資材名') material_type = models.CharField( max_length=20, choices=MaterialType.choices, verbose_name='資材種別' ) maker = models.CharField( max_length=100, blank=True, default='', verbose_name='メーカー' ) stock_unit = models.CharField( max_length=20, choices=StockUnit.choices, default='bag', verbose_name='在庫単位' ) is_active = models.BooleanField(default=True, verbose_name='使用中') notes = models.TextField(blank=True, default='', verbose_name='備考') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['material_type', 'name'] constraints = [ models.UniqueConstraint( fields=['material_type', 'name'], name='uniq_material_type_name', ), ] verbose_name = '資材' verbose_name_plural = '資材' def __str__(self): return f'{self.get_material_type_display()}: {self.name}' class FertilizerProfile(models.Model): """肥料専用属性""" material = models.OneToOneField( Material, on_delete=models.CASCADE, related_name='fertilizer_profile' ) capacity_kg = models.DecimalField( max_digits=8, decimal_places=3, blank=True, null=True, verbose_name='1袋重量(kg)' ) nitrogen_pct = models.DecimalField( max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='窒素(%)' ) phosphorus_pct = models.DecimalField( max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='リン酸(%)' ) potassium_pct = models.DecimalField( max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ(%)' ) class Meta: verbose_name = '肥料詳細' verbose_name_plural = '肥料詳細' def __str__(self): return f'肥料詳細: {self.material.name}' class PesticideProfile(models.Model): """農薬専用属性""" material = models.OneToOneField( Material, on_delete=models.CASCADE, related_name='pesticide_profile' ) registration_no = models.CharField( max_length=100, blank=True, default='', verbose_name='農薬登録番号' ) formulation = models.CharField( max_length=100, blank=True, default='', verbose_name='剤型' ) usage_unit = models.CharField( max_length=50, blank=True, default='', verbose_name='使用単位' ) dilution_ratio = models.CharField( max_length=100, blank=True, default='', verbose_name='希釈倍率' ) active_ingredient = models.CharField( max_length=200, blank=True, default='', verbose_name='有効成分' ) category = models.CharField( max_length=100, blank=True, default='', verbose_name='分類' ) class Meta: verbose_name = '農薬詳細' verbose_name_plural = '農薬詳細' def __str__(self): return f'農薬詳細: {self.material.name}' class StockTransaction(models.Model): """入出庫履歴""" class TransactionType(models.TextChoices): PURCHASE = 'purchase', '入庫' USE = 'use', '使用' ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増' ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減' DISCARD = 'discard', '廃棄' # 増加として扱う transaction_type のセット INCREASE_TYPES = { TransactionType.PURCHASE, TransactionType.ADJUSTMENT_PLUS, } material = models.ForeignKey( Material, on_delete=models.PROTECT, related_name='stock_transactions', verbose_name='資材' ) transaction_type = models.CharField( max_length=30, choices=TransactionType.choices, verbose_name='取引種別' ) quantity = models.DecimalField( max_digits=10, decimal_places=3, verbose_name='数量' ) occurred_on = models.DateField(verbose_name='発生日') note = models.TextField(blank=True, default='', verbose_name='備考') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-occurred_on', '-created_at'] verbose_name = '入出庫履歴' verbose_name_plural = '入出庫履歴' def __str__(self): return f'{self.material.name} {self.get_transaction_type_display()} {self.quantity}' ``` **注意**: `StockTransaction` から以下のフィールドを意図的に除外した: - `unit` — `Material.stock_unit` から取得すれば十分 - `reference_type` / `reference_id` — Generic FK は初期段階で不要 - `created_by` — シングルユーザーシステムのため不要 - `inventory_count` — `adjustment_plus` / `adjustment_minus` で十分表現できる ### 2.3 既存 Fertilizer との連携マイグレーション **重要**: これは2段階で行う。 #### ステップ1: Material 関連テーブル作成 ```bash python manage.py makemigrations materials ``` #### ステップ2: Fertilizer に material FK 追加 + データ移行 `apps/fertilizer/` に新しいマイグレーションを手動作成する。 ファイル名: `apps/fertilizer/migrations/0005_fertilizer_material.py` ```python from django.db import migrations, models import django.db.models.deletion def create_materials_for_existing_fertilizers(apps, schema_editor): """既存の Fertilizer ごとに Material + FertilizerProfile を作成し紐づける""" Fertilizer = apps.get_model('fertilizer', 'Fertilizer') Material = apps.get_model('materials', 'Material') FertilizerProfile = apps.get_model('materials', 'FertilizerProfile') for fert in Fertilizer.objects.all(): mat = Material.objects.create( name=fert.name, material_type='fertilizer', maker=fert.maker or '', stock_unit='bag', is_active=True, notes=fert.notes or '', ) FertilizerProfile.objects.create( material=mat, capacity_kg=fert.capacity_kg, nitrogen_pct=fert.nitrogen_pct, phosphorus_pct=fert.phosphorus_pct, potassium_pct=fert.potassium_pct, ) fert.material = mat fert.save() def reverse_migration(apps, schema_editor): """逆マイグレーション: material FK を null に戻す""" Fertilizer = apps.get_model('fertilizer', 'Fertilizer') Fertilizer.objects.all().update(material=None) class Migration(migrations.Migration): dependencies = [ ('fertilizer', '0004_fertilizationplan_calc_settings'), ('materials', '0001_initial'), ] operations = [ # Step 1: nullable FK として追加 migrations.AddField( model_name='fertilizer', name='material', field=models.OneToOneField( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_fertilizer', to='materials.Material', verbose_name='資材マスタ', ), ), # Step 2: 既存データを移行 migrations.RunPython( create_materials_for_existing_fertilizers, reverse_migration, ), ] ``` `apps/fertilizer/models.py` の `Fertilizer` クラスに追加: ```python # 既存フィールドの後に追加 material = models.OneToOneField( 'materials.Material', on_delete=models.SET_NULL, null=True, blank=True, related_name='legacy_fertilizer', verbose_name='資材マスタ', ) ``` **注意**: `material` は `null=True` のままにする。施肥計画の既存ロジックには影響しない。 ### 2.4 Serializer (`apps/materials/serializers.py`) ```python from decimal import Decimal from django.db.models import Sum, Q from rest_framework import serializers from .models import Material, FertilizerProfile, PesticideProfile, StockTransaction class FertilizerProfileSerializer(serializers.ModelSerializer): class Meta: model = FertilizerProfile fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct'] class PesticideProfileSerializer(serializers.ModelSerializer): class Meta: model = PesticideProfile fields = [ 'registration_no', 'formulation', 'usage_unit', 'dilution_ratio', 'active_ingredient', 'category', ] class MaterialReadSerializer(serializers.ModelSerializer): """Material 読み取り用(プロファイル・在庫含む)""" material_type_display = serializers.CharField( source='get_material_type_display', read_only=True ) stock_unit_display = serializers.CharField( source='get_stock_unit_display', read_only=True ) fertilizer_profile = FertilizerProfileSerializer(read_only=True) pesticide_profile = PesticideProfileSerializer(read_only=True) current_stock = serializers.SerializerMethodField() class Meta: model = Material fields = [ 'id', 'name', 'material_type', 'material_type_display', 'maker', 'stock_unit', 'stock_unit_display', 'is_active', 'notes', 'fertilizer_profile', 'pesticide_profile', 'current_stock', 'created_at', 'updated_at', ] def get_current_stock(self, obj): """StockTransaction の集計で現在庫を算出""" txns = obj.stock_transactions.all() increase = txns.filter( transaction_type__in=['purchase', 'adjustment_plus'] ).aggregate(total=Sum('quantity'))['total'] or Decimal('0') decrease = txns.filter( transaction_type__in=['use', 'adjustment_minus', 'discard'] ).aggregate(total=Sum('quantity'))['total'] or Decimal('0') return str(increase - decrease) class MaterialWriteSerializer(serializers.ModelSerializer): """Material 書き込み用""" fertilizer_profile = FertilizerProfileSerializer(required=False) pesticide_profile = PesticideProfileSerializer(required=False) class Meta: model = Material fields = [ 'id', 'name', 'material_type', 'maker', 'stock_unit', 'is_active', 'notes', 'fertilizer_profile', 'pesticide_profile', ] def create(self, validated_data): fert_data = validated_data.pop('fertilizer_profile', None) pest_data = validated_data.pop('pesticide_profile', None) material = Material.objects.create(**validated_data) if validated_data['material_type'] == 'fertilizer' and fert_data: FertilizerProfile.objects.create(material=material, **fert_data) elif validated_data['material_type'] == 'pesticide' and pest_data: PesticideProfile.objects.create(material=material, **pest_data) return material def update(self, instance, validated_data): fert_data = validated_data.pop('fertilizer_profile', None) pest_data = validated_data.pop('pesticide_profile', None) for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() if instance.material_type == 'fertilizer' and fert_data is not None: profile, _ = FertilizerProfile.objects.get_or_create(material=instance) for attr, value in fert_data.items(): setattr(profile, attr, value) profile.save() elif instance.material_type == 'pesticide' and pest_data is not None: profile, _ = PesticideProfile.objects.get_or_create(material=instance) for attr, value in pest_data.items(): setattr(profile, attr, value) profile.save() return instance class StockTransactionSerializer(serializers.ModelSerializer): """入出庫履歴""" material_name = serializers.CharField(source='material.name', read_only=True) material_type = serializers.CharField(source='material.material_type', read_only=True) stock_unit = serializers.CharField(source='material.stock_unit', read_only=True) stock_unit_display = serializers.CharField( source='material.get_stock_unit_display', read_only=True ) transaction_type_display = serializers.CharField( source='get_transaction_type_display', read_only=True ) class Meta: model = StockTransaction fields = [ 'id', 'material', 'material_name', 'material_type', 'transaction_type', 'transaction_type_display', 'quantity', 'stock_unit', 'stock_unit_display', 'occurred_on', 'note', 'created_at', ] read_only_fields = ['created_at'] class StockSummarySerializer(serializers.Serializer): """在庫集計(読み取り専用)""" material_id = serializers.IntegerField() name = serializers.CharField() material_type = serializers.CharField() material_type_display = serializers.CharField() stock_unit = serializers.CharField() stock_unit_display = serializers.CharField() is_active = serializers.BooleanField() current_stock = serializers.DecimalField(max_digits=10, decimal_places=3) last_transaction_date = serializers.DateField(allow_null=True) ``` ### 2.5 View (`apps/materials/views.py`) ```python from decimal import Decimal from django.db.models import Sum, Max, Q, Value, CharField from django.db.models.functions import Coalesce from rest_framework import viewsets, generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .models import Material, StockTransaction from .serializers import ( MaterialReadSerializer, MaterialWriteSerializer, StockTransactionSerializer, StockSummarySerializer, ) class MaterialViewSet(viewsets.ModelViewSet): """資材マスタ CRUD""" permission_classes = [IsAuthenticated] def get_queryset(self): qs = Material.objects.select_related( 'fertilizer_profile', 'pesticide_profile' ).prefetch_related('stock_transactions') material_type = self.request.query_params.get('material_type') if material_type: qs = qs.filter(material_type=material_type) active = self.request.query_params.get('active') if active is not None: qs = qs.filter(is_active=active.lower() == 'true') return qs def get_serializer_class(self): if self.action in ('create', 'update', 'partial_update'): return MaterialWriteSerializer return MaterialReadSerializer def destroy(self, request, *args, **kwargs): instance = self.get_object() if instance.stock_transactions.exists(): return Response( {'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'}, status=status.HTTP_400_BAD_REQUEST, ) return super().destroy(request, *args, **kwargs) class StockTransactionViewSet(viewsets.ModelViewSet): """入出庫履歴 CRUD""" serializer_class = StockTransactionSerializer permission_classes = [IsAuthenticated] http_method_names = ['get', 'post', 'delete', 'head', 'options'] def get_queryset(self): qs = StockTransaction.objects.select_related('material') material_id = self.request.query_params.get('material_id') if material_id: qs = qs.filter(material_id=material_id) material_type = self.request.query_params.get('material_type') if material_type: qs = qs.filter(material__material_type=material_type) date_from = self.request.query_params.get('date_from') if date_from: qs = qs.filter(occurred_on__gte=date_from) date_to = self.request.query_params.get('date_to') if date_to: qs = qs.filter(occurred_on__lte=date_to) return qs class StockSummaryView(generics.ListAPIView): """在庫集計一覧""" serializer_class = StockSummarySerializer permission_classes = [IsAuthenticated] def get_queryset(self): return None def list(self, request, *args, **kwargs): qs = Material.objects.prefetch_related('stock_transactions') material_type = request.query_params.get('material_type') if material_type: qs = qs.filter(material_type=material_type) active = request.query_params.get('active') if active is not None: qs = qs.filter(is_active=active.lower() == 'true') results = [] for mat in qs: txns = mat.stock_transactions.all() increase = sum( t.quantity for t in txns if t.transaction_type in ('purchase', 'adjustment_plus') ) decrease = sum( t.quantity for t in txns if t.transaction_type in ('use', 'adjustment_minus', 'discard') ) last_date = max( (t.occurred_on for t in txns), default=None ) results.append({ 'material_id': mat.id, 'name': mat.name, 'material_type': mat.material_type, 'material_type_display': mat.get_material_type_display(), 'stock_unit': mat.stock_unit, 'stock_unit_display': mat.get_stock_unit_display(), 'is_active': mat.is_active, 'current_stock': increase - decrease, 'last_transaction_date': last_date, }) serializer = StockSummarySerializer(results, many=True) return Response(serializer.data) ``` ### 2.6 URL (`apps/materials/urls.py`) ```python from django.urls import path, include from rest_framework.routers import DefaultRouter from . import views router = DefaultRouter() router.register(r'materials', views.MaterialViewSet, basename='material') router.register(r'stock-transactions', views.StockTransactionViewSet, basename='stock-transaction') urlpatterns = [ path('', include(router.urls)), path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'), ] ``` ### 2.7 Admin (`apps/materials/admin.py`) ```python from django.contrib import admin from .models import Material, FertilizerProfile, PesticideProfile, StockTransaction class FertilizerProfileInline(admin.StackedInline): model = FertilizerProfile extra = 0 class PesticideProfileInline(admin.StackedInline): model = PesticideProfile extra = 0 @admin.register(Material) class MaterialAdmin(admin.ModelAdmin): list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active'] list_filter = ['material_type', 'is_active'] search_fields = ['name', 'maker'] inlines = [FertilizerProfileInline, PesticideProfileInline] @admin.register(StockTransaction) class StockTransactionAdmin(admin.ModelAdmin): list_display = ['material', 'transaction_type', 'quantity', 'occurred_on'] list_filter = ['transaction_type', 'occurred_on'] search_fields = ['material__name'] ``` --- ## 3. フロントエンド実装 ### 3.1 型定義 (`frontend/src/types/index.ts` に追記) ```typescript // ===== 資材管理 ===== export interface FertilizerProfile { capacity_kg: string | null; nitrogen_pct: string | null; phosphorus_pct: string | null; potassium_pct: string | null; } export interface PesticideProfile { registration_no: string; formulation: string; usage_unit: string; dilution_ratio: string; active_ingredient: string; category: string; } export interface Material { id: number; name: string; material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other'; material_type_display: string; maker: string; stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece'; stock_unit_display: string; is_active: boolean; notes: string; fertilizer_profile: FertilizerProfile | null; pesticide_profile: PesticideProfile | null; current_stock: string; created_at: string; updated_at: string; } export interface StockTransaction { id: number; material: number; material_name: string; material_type: string; transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard'; transaction_type_display: string; quantity: string; stock_unit: string; stock_unit_display: string; occurred_on: string; note: string; created_at: string; } export interface StockSummary { material_id: number; name: string; material_type: string; material_type_display: string; stock_unit: string; stock_unit_display: string; is_active: boolean; current_stock: string; last_transaction_date: string | null; } ``` ### 3.2 画面構成 ``` frontend/src/app/materials/ ├── page.tsx # 在庫一覧(メイン画面) ├── masters/ │ └── page.tsx # 資材マスタ管理 └── _components/ ├── StockOverview.tsx # 在庫一覧コンポーネント ├── StockTransactionForm.tsx # 入出庫登録フォーム(モーダルまたはインライン) └── MaterialForm.tsx # 資材登録・編集フォーム ``` ### 3.3 在庫一覧画面 (`/materials` → `page.tsx`) **機能:** - 全資材の現在庫を一覧表示 - `material_type` でフィルタ(タブ: 全て / 肥料 / 農薬 / その他) - 「入出庫登録」ボタン → モーダルで入出庫フォームを開く - 「資材マスタ」ボタン → `/materials/masters` へ遷移 **テーブル列:** | 資材名 | 種別 | メーカー | 現在庫 | 単位 | 最終入出庫日 | 操作 | **操作列:** - 「入庫」ボタン(クリック → 入出庫フォームをプリセット `transaction_type=purchase`) - 「出庫」ボタン(クリック → 入出庫フォームをプリセット `transaction_type=use`) - 「履歴」ボタン(クリック → 資材の入出庫履歴をインライン展開 or モーダル表示) **API:** - `GET /api/materials/stock-summary/` で一覧取得 - `GET /api/materials/stock-summary/?material_type=fertilizer` でフィルタ ### 3.4 入出庫登録フォーム (`StockTransactionForm.tsx`) **モーダルで表示する。** **フォーム項目:** - 資材(セレクト。一覧画面から呼ぶ場合はプリセット済み) - 取引種別(セレクト: 入庫 / 使用 / 棚卸増 / 棚卸減 / 廃棄) - 数量(数値入力) - 発生日(日付入力、デフォルト: 今日) - 備考(テキスト、任意) **API:** - `POST /api/materials/stock-transactions/` **保存後:** - モーダルを閉じる - 在庫一覧を再取得 ### 3.5 資材マスタ管理画面 (`/materials/masters` → `masters/page.tsx`) **既存の `/fertilizer/masters` と同じ UX パターンを踏襲する。** **機能:** - 資材一覧(インライン編集テーブル) - 新規追加(行追加) - 編集(行内で直接編集) - 削除(入出庫履歴がない場合のみ) - `material_type` タブ(肥料 / 農薬 / その他) **肥料タブの列:** | 資材名 | メーカー | 1袋(kg) | 窒素(%) | リン酸(%) | カリ(%) | 単位 | 備考 | 操作 | **農薬タブの列:** | 資材名 | メーカー | 登録番号 | 剤型 | 有効成分 | 分類 | 単位 | 備考 | 操作 | **API:** - `GET /api/materials/materials/?material_type=fertilizer` - `POST /api/materials/materials/` (body に `fertilizer_profile` または `pesticide_profile` を含む) - `PUT /api/materials/materials/{id}/` - `DELETE /api/materials/materials/{id}/` ### 3.6 Navbar への追加 `frontend/src/components/Navbar.tsx` に以下を追加: ```tsx // icon import に追加 import { Package } from 'lucide-react'; // メニューボタン(「分配計画」の後に追加) ``` --- ## 4. API エンドポイント一覧 | メソッド | パス | 認証 | 説明 | |----------|------|------|------| | GET | `/api/materials/materials/` | JWT | 資材一覧(`?material_type=`, `?active=`) | | POST | `/api/materials/materials/` | JWT | 資材作成(profile 込み) | | GET | `/api/materials/materials/{id}/` | JWT | 資材詳細 | | PUT | `/api/materials/materials/{id}/` | JWT | 資材更新(profile 込み) | | DELETE | `/api/materials/materials/{id}/` | JWT | 資材削除(履歴なしの場合のみ) | | GET | `/api/materials/stock-transactions/` | JWT | 入出庫履歴一覧(`?material_id=`, `?material_type=`, `?date_from=`, `?date_to=`) | | POST | `/api/materials/stock-transactions/` | JWT | 入出庫登録 | | DELETE | `/api/materials/stock-transactions/{id}/` | JWT | 入出庫削除 | | GET | `/api/materials/stock-summary/` | JWT | 在庫集計一覧(`?material_type=`, `?active=`) | --- ## 5. 実装順序(厳守) ### Step 1: バックエンド基盤 1. `apps/materials/` ディレクトリ作成 2. `models.py` 作成(Material, FertilizerProfile, PesticideProfile, StockTransaction) 3. `apps.py` 作成 4. `settings.py` に `apps.materials` 追加 5. `python manage.py makemigrations materials` 6. `python manage.py migrate` ### Step 2: 既存 Fertilizer 連携 7. `apps/fertilizer/models.py` に `material` フィールド追加 8. `apps/fertilizer/migrations/0005_fertilizer_material.py` 作成(データ移行込み) 9. `python manage.py migrate` ### Step 3: バックエンド API 10. `serializers.py` 作成 11. `views.py` 作成 12. `urls.py` 作成 13. `admin.py` 作成 14. `keinasystem/urls.py` にルート追加 ### Step 4: フロントエンド 15. `types/index.ts` に型定義追加 16. `app/materials/page.tsx` 作成(在庫一覧) 17. `app/materials/_components/StockTransactionForm.tsx` 作成 18. `app/materials/_components/StockOverview.tsx` 作成 19. `app/materials/masters/page.tsx` 作成(資材マスタ管理) 20. `app/materials/_components/MaterialForm.tsx` 作成 21. `Navbar.tsx` にメニュー追加 --- ## 6. テスト確認項目 ### バックエンド - [ ] `GET /api/materials/materials/` で空リストが返る - [ ] `POST /api/materials/materials/` で肥料を作成できる(`fertilizer_profile` 込み) - [ ] `POST /api/materials/materials/` で農薬を作成できる(`pesticide_profile` 込み) - [ ] `DELETE /api/materials/materials/{id}/` で入出庫履歴がある資材は削除拒否 - [ ] `POST /api/materials/stock-transactions/` で入庫登録できる - [ ] `GET /api/materials/stock-summary/` で現在庫が正しく集計される - [ ] 入庫3袋 + 使用1袋 = 現在庫2袋 - [ ] マイグレーション後、既存 Fertilizer が Material に紐づいている ### フロントエンド - [ ] `/materials` で在庫一覧が表示される - [ ] 入出庫登録モーダルが開き、保存後に一覧が更新される - [ ] `/materials/masters` で資材マスタのインライン編集ができる - [ ] Navbar の「在庫管理」から遷移できる - [ ] 施肥計画画面(`/fertilizer`)が変更なく動作する --- ## 7. 既存コードへの変更一覧(影響範囲) | ファイル | 変更内容 | |----------|----------| | `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.materials'` 追加 | | `backend/keinasystem/urls.py` | `path('api/materials/', ...)` 追加 | | `backend/apps/fertilizer/models.py` | `Fertilizer` に `material` OneToOneField 追加 | | `backend/apps/fertilizer/migrations/` | `0005_fertilizer_material.py` 追加 | | `frontend/src/types/index.ts` | Material 関連の型定義追加 | | `frontend/src/components/Navbar.tsx` | 「在庫管理」メニュー追加 | 上記以外の既存ファイルには**一切触らない**。 --- ## 8. 参照すべき既存コード(実装パターンの手本) | 目的 | 参照先 | |------|--------| | Django アプリ構成 | `backend/apps/weather/` | | ViewSet + Router パターン | `backend/apps/fertilizer/views.py` | | インライン編集テーブル UI | `frontend/src/app/fertilizer/masters/page.tsx` | | 施肥計画一覧(年度セレクタ等) | `frontend/src/app/fertilizer/page.tsx` | | Navbar ボタン追加 | `frontend/src/components/Navbar.tsx` | | TypeScript 型定義 | `frontend/src/types/index.ts` | | API 呼び出し | `frontend/src/lib/api.ts` | | Next.js 14 ダイナミックルート | `frontend/src/app/fertilizer/[id]/edit/page.tsx` |