在庫管理機能実装案をレビュー反映し、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:
956
CODEX.md
Normal file
956
CODEX.md
Normal 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` |
|
||||
@@ -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 のみを対象とする
|
||||
|
||||
Reference in New Issue
Block a user