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

957 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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';
// メニューボタン(「分配計画」の後に追加)
<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.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` |