- 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>
957 lines
33 KiB
Markdown
957 lines
33 KiB
Markdown
# 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` |
|