diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 0000000..3016f46 --- /dev/null +++ b/CODEX.md @@ -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'; + +// メニューボタン(「分配計画」の後に追加) + +``` + +--- + +## 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` | diff --git a/改善案/在庫管理機能実装案.md b/改善案/在庫管理機能実装案.md index 2ace991..78ee1ef 100644 --- a/改善案/在庫管理機能実装案.md +++ b/改善案/在庫管理機能実装案.md @@ -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 のみを対象とする