- 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>
33 KiB
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 ...
絶対ルール
- 既存の
apps/fertilizer/のモデル・API・画面を壊さない Fertilizerモデルは改名・削除しない(本番稼働中)FertilizationEntry → Fertilizerの FK は変更しない- マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない
- フロントエンドでは
alert()/confirm()を使わない(インラインバナーで表示) - API エンドポイントは複数形 (
/api/materials/, NOT/api/material/) - Django のベストプラクティスに従う(ViewSet, Serializer, Router パターン)
- TypeScript strict mode に従う
- Next.js 14 では
paramsは通常のオブジェクト(use(params)は使わない)
1. 実装スコープ(Phase 1 のみ)
やること
apps/materialsDjango アプリを新規作成Materialモデル(共通資材マスタ)FertilizerProfileモデル(肥料詳細、Material と 1:1)PesticideProfileモデル(農薬詳細、Material と 1:1)StockTransactionモデル(入出庫履歴)- 既存
FertilizerにmaterialOneToOneField を追加(データ移行込み) - 資材マスタ CRUD API
- 在庫履歴 API
- 在庫集計 API
- フロントエンド: 在庫一覧画面
- フロントエンド: 入出庫登録画面
- フロントエンド: 資材マスタ管理画面
- 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 から以下のフィールドを意図的に除外した:
unit—Material.stock_unitから取得すれば十分reference_type/reference_id— Generic FK は初期段階で不要created_by— シングルユーザーシステムのため不要inventory_count—adjustment_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.py の Fertilizer クラスに追加:
# 既存フィールドの後に追加
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)
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 在庫一覧画面 (/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=fertilizerPOST /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: バックエンド基盤
apps/materials/ディレクトリ作成models.py作成(Material, FertilizerProfile, PesticideProfile, StockTransaction)apps.py作成settings.pyにapps.materials追加python manage.py makemigrations materialspython manage.py migrate
Step 2: 既存 Fertilizer 連携
apps/fertilizer/models.pyにmaterialフィールド追加apps/fertilizer/migrations/0005_fertilizer_material.py作成(データ移行込み)python manage.py migrate
Step 3: バックエンド API
serializers.py作成views.py作成urls.py作成admin.py作成keinasystem/urls.pyにルート追加
Step 4: フロントエンド
types/index.tsに型定義追加app/materials/page.tsx作成(在庫一覧)app/materials/_components/StockTransactionForm.tsx作成app/materials/_components/StockOverview.tsx作成app/materials/masters/page.tsx作成(資材マスタ管理)app/materials/_components/MaterialForm.tsx作成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 |