在庫管理機能実装案をレビュー反映し、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>
This commit is contained in:
Akira
2026-03-14 15:03:09 +09:00
parent 1b619c44a0
commit 67d4197b7f
2 changed files with 1004 additions and 17 deletions

956
CODEX.md Normal file
View File

@@ -0,0 +1,956 @@
# 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` |

View File

@@ -146,23 +146,28 @@
- `material` `ForeignKey(Material)`
- `transaction_type`
- `quantity`
- `unit`
- `occurred_on`
- `reference_type`
- `reference_id`
- `note`
- `created_by`
- `created_at`
以下のフィールドは初期実装では **除外** する:
- ~~`unit`~~ → `Material.stock_unit` から取得すれば十分。トランザクションごとに単位を持つと不整合の元になる
- ~~`reference_type`~~ / ~~`reference_id`~~ → Generic FK パターンは複雑化の元。将来の施肥計画連携時に必要なら追加する
- ~~`created_by`~~ → シングルユーザーシステムのため不要。マルチユーザー対応時に追加する
`transaction_type` の候補:
- `purchase`
- `use`
- `adjustment_plus`
- `adjustment_minus`
- `inventory_count`
- `discard`
以下は初期実装では除外する:
- ~~`inventory_count`~~ → `adjustment_plus` / `adjustment_minus` で棚卸差異を十分表現できる
`quantity` は正の数で持ち、増減方向は `transaction_type` で判定する方式を推奨する。
#### MaterialStockSnapshot
@@ -237,15 +242,13 @@ class StockTransaction(models.Model):
USE = 'use', '使用'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
INVENTORY_COUNT = 'inventory_count', '棚卸記録'
DISCARD = 'discard', '廃棄'
material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name='stock_transactions')
transaction_type = models.CharField(max_length=30, choices=TransactionType.choices)
quantity = models.DecimalField(max_digits=10, decimal_places=3)
occurred_on = models.DateField()
note = models.TextField(blank=True, null=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
note = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
```
@@ -425,15 +428,14 @@ backend/apps/materials/
### 9.1 新規画面
- `/materials`
- 資材一覧
- `/materials/fertilizers`
- 肥料マスタ管理
- `/materials/pesticides`
- 農薬マスタ管理
- `/materials/stock`
- 在庫一覧
- `/materials/stock/new`
- 入出庫登録
- 在庫一覧(メイン画面)。在庫集計表 + 入出庫登録モーダル
- `/materials/masters`
- 資材マスタ管理(肥料/農薬/その他をタブ切り替え、インライン編集テーブル)
**設計判断**: 入出庫登録は独立ページではなくモーダル方式にする。
理由: 既存の肥料マスタ画面(`/fertilizer/masters`)のインライン編集パターンに合わせ、
画面遷移を減らして操作効率を上げるため。
肥料マスタと農薬マスタを別ページに分けず、1画面でタブ切り替えにする。
### 9.2 一覧画面の基本列
@@ -1550,3 +1552,32 @@ alias 例:
- 補助: `alias辞書`
この構成であれば、コストを抑えつつ、現場名・ブランド名・保証票名の差異にも対応しやすい。
---
## 22. レビュー記録2026-03-14
既存コードとの整合性レビューを実施し、以下の修正を反映した。
### 22.1 StockTransaction フィールドの簡素化
以下のフィールドを初期実装から除外した。
| 除外フィールド | 理由 |
|----------------|------|
| `unit` | `Material.stock_unit` から取得すれば十分。トランザクションごとに単位を持つと不整合の元 |
| `reference_type` / `reference_id` | Generic FK パターンは複雑化の元。将来の施肥計画連携時に必要なら追加 |
| `created_by` | シングルユーザーシステムのため不要。マルチユーザー対応時に追加 |
| `inventory_count` | `adjustment_plus` / `adjustment_minus` で棚卸差異を十分表現可能 |
### 22.2 フロントエンド画面構成の変更
- 入出庫登録を独立ページ (`/materials/stock/new`) → モーダル方式に変更
- 肥料マスタ・農薬マスタを別ページ → 1画面タブ切り替え (`/materials/masters`) に統合
- 理由: 既存の `/fertilizer/masters` のインライン編集パターンに合わせ、画面遷移を減らす
### 22.3 実装フェーズの整理
- セクション1〜16: Phase 1初期実装
- セクション17〜21: Phase 2公式データ同期、alias辞書、LLM調査支援
- CODEX への初回作業指示は Phase 1 のみを対象とする