Files
keinasystem/CODEX.md
Akira 67d4197b7f 在庫管理機能実装案をレビュー反映し、CODEX実装指示書を追加
- StockTransaction から冗長フィールド除外(unit, reference_type/id, created_by, inventory_count)
- フロントエンド画面構成を変更(入出庫登録をモーダル化、マスタ管理をタブ統合)
- レビュー記録セクション22を追加
- CODEX.md: Phase 1 実装指示書を作成(モデル・API・画面の詳細仕様)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:03:09 +09:00

33 KiB
Raw Blame History

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. 既存 Fertilizermaterial OneToOneField を追加(データ移行込み)
  7. 資材マスタ CRUD API
  8. 在庫履歴 API
  9. 在庫集計 API
  10. フロントエンド: 在庫一覧画面
  11. フロントエンド: 入出庫登録画面
  12. フロントエンド: 資材マスタ管理画面
  13. Navbar にメニュー追加

やらないこと(将来フェーズ)

  • 公式データ同期FAMIC、農水省
  • 別名辞書MaterialAlias
  • LLM 調査支援
  • 施肥計画画面への在庫参照表示
  • 在庫自動減算(施肥計画確定時)
  • MaterialStockSnapshot(パフォーマンス最適化)

2. バックエンド実装

2.1 アプリ作成

cd backend
python manage.py startapp materials
mv materials apps/materials

apps/materials/apps.py:

from django.apps import AppConfig

class MaterialsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'apps.materials'
    verbose_name = '資材管理'

backend/keinasystem/settings.py に追加:

INSTALLED_APPS = [
    # ... 既存 ...
    'apps.materials',  # ← 追加
]

backend/keinasystem/urls.py に追加:

urlpatterns = [
    # ... 既存 ...
    path('api/materials/', include('apps.materials.urls')),  # ← 追加
]

2.2 モデル定義 (apps/materials/models.py)

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 から以下のフィールドを意図的に除外した:

  • unitMaterial.stock_unit から取得すれば十分
  • reference_type / reference_id — Generic FK は初期段階で不要
  • created_by — シングルユーザーシステムのため不要
  • inventory_countadjustment_plus / adjustment_minus で十分表現できる

2.3 既存 Fertilizer との連携マイグレーション

重要: これは2段階で行う。

ステップ1: Material 関連テーブル作成

python manage.py makemigrations materials

ステップ2: Fertilizer に material FK 追加 + データ移行

apps/fertilizer/ に新しいマイグレーションを手動作成する。

ファイル名: apps/fertilizer/migrations/0005_fertilizer_material.py

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.pyFertilizer クラスに追加:

# 既存フィールドの後に追加
material = models.OneToOneField(
    'materials.Material',
    on_delete=models.SET_NULL,
    null=True,
    blank=True,
    related_name='legacy_fertilizer',
    verbose_name='資材マスタ',
)

注意: materialnull=True のままにする。施肥計画の既存ロジックには影響しない。

2.4 Serializer (apps/materials/serializers.py)

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)

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)

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)

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 に追記)

// ===== 資材管理 =====

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 在庫一覧画面 (/materialspage.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/mastersmasters/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 に以下を追加:

// icon import に追加
import { Package } from 'lucide-react';

// メニューボタン(「分配計画」の後に追加)
<button
  onClick={() => router.push('/materials')}
  className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
    pathname?.startsWith('/materials')
      ? 'text-green-700 bg-green-50'
      : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
  }`}
>
  <Package className="h-4 w-4 mr-2" />
  在庫管理
</button>

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.pyapps.materials 追加
  5. python manage.py makemigrations materials
  6. python manage.py migrate

Step 2: 既存 Fertilizer 連携

  1. apps/fertilizer/models.pymaterial フィールド追加
  2. apps/fertilizer/migrations/0005_fertilizer_material.py 作成(データ移行込み)
  3. python manage.py migrate

Step 3: バックエンド API

  1. serializers.py 作成
  2. views.py 作成
  3. urls.py 作成
  4. admin.py 作成
  5. keinasystem/urls.py にルート追加

Step 4: フロントエンド

  1. types/index.ts に型定義追加
  2. app/materials/page.tsx 作成(在庫一覧)
  3. app/materials/_components/StockTransactionForm.tsx 作成
  4. app/materials/_components/StockOverview.tsx 作成
  5. app/materials/masters/page.tsx 作成(資材マスタ管理)
  6. app/materials/_components/MaterialForm.tsx 作成
  7. 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 Fertilizermaterial 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