diff --git a/CODEX.md b/CODEX.md index 3016f46..5dcc538 100644 --- a/CODEX.md +++ b/CODEX.md @@ -1,235 +1,98 @@ -# CODEX 実装指示書: 在庫管理機能(Phase 1) +# CODEX 実装指示書: 施肥計画連携・引当機能(Phase 1.5) > 作成日: 2026-03-14 > 対象: `keinasystem_t02` -> 設計案: `改善案/在庫管理機能実装案.md`(セクション1〜16が対象。17〜21は将来フェーズ) +> 設計案: `改善案/在庫管理機能実装案.md`(セクション23が対象) +> 前提: Phase 1(セクション1〜16)は実装済み。`apps/materials` が稼働中。 --- ## 0. 実装の前提と絶対ルール -### プロジェクト構造 +### 現在のプロジェクト構造(Phase 1 実装済み) ``` keinasystem_t02/ ├── backend/ │ ├── keinasystem/ -│ │ ├── settings.py # INSTALLED_APPS にアプリ登録 -│ │ └── urls.py # ルートURL登録 +│ │ ├── settings.py # apps.materials 登録済み +│ │ └── urls.py # /api/materials/ 登録済み │ └── apps/ -│ ├── fields/ # 圃場管理(Field モデル) -│ ├── plans/ # 作付け計画(Crop, Variety モデル) -│ ├── fertilizer/ # 施肥計画(Fertilizer, FertilizationPlan 等) -│ └── materials/ # ← 新規作成 +│ ├── fields/ # 圃場管理(Field モデル) +│ ├── plans/ # 作付け計画(Crop, Variety モデル) +│ ├── fertilizer/ # 施肥計画(Fertilizer, FertilizationPlan, FertilizationEntry 等) +│ │ └── models.py # Fertilizer.material = OneToOneField(Material) 追加済み +│ └── materials/ # 在庫管理(Material, FertilizerProfile, PesticideProfile, StockTransaction) +│ └── models.py # Phase 1 で作成済み └── frontend/ └── src/ - ├── types/index.ts # 型定義(ここに追記) - ├── lib/api.ts # axios インスタンス(変更不要) + ├── types/index.ts # Material, StockTransaction, StockSummary 定義済み + ├── lib/api.ts # axios インスタンス(変更不要) ├── components/ - │ └── Navbar.tsx # ナビゲーション(メニュー追加) + │ └── Navbar.tsx # 在庫管理メニュー追加済み └── app/ - └── materials/ # ← 新規作成 + ├── fertilizer/ # 施肥計画(既存)← 今回変更対象 + │ ├── page.tsx + │ ├── [id]/edit/page.tsx + │ └── _components/FertilizerEditPage.tsx + └── materials/ # 在庫管理(Phase 1 で作成済み)← 今回変更対象 + ├── page.tsx + └── _components/StockOverview.tsx ``` ### 技術スタック - Backend: Django 5.2 + Django REST Framework + PostgreSQL 16 - Frontend: Next.js 14 (App Router) + TypeScript strict + Tailwind CSS +- 認証: SimpleJWT(ヘッダー `Authorization: Bearer `) - 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. **既存の施肥計画 CRUD(作成・編集・削除・PDF)を壊さない** +2. **`FertilizationEntry → Fertilizer` の FK は変更しない** +3. **`Fertilizer` モデルは改名・削除しない** +4. **フロントエンドでは `alert()` / `confirm()` を使わない**(インラインバナーで表示) +5. **TypeScript strict mode に従う** +6. **Next.js 14 では `params` は通常のオブジェクト**(`use(params)` は使わない) +7. **マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない** --- -## 1. 実装スコープ(Phase 1 のみ) +## 1. 実装スコープ(Phase 1.5) ### やること -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 にメニュー追加 +1. `StockTransaction` に `reserve` タイプ追加 +2. `StockTransaction` に `fertilization_plan` FK 追加(マイグレーション) +3. `FertilizationPlan` に `is_confirmed` / `confirmed_at` 追加(マイグレーション) +4. 在庫集計 API に `reserved_stock` / `available_stock` 追加 +5. 施肥計画の保存時に引当(reserve)を自動作成 +6. 施肥計画の削除時に引当を自動解除 +7. 散布確定 API(`confirm_spreading`) +8. 肥料在庫一覧 API(施肥計画画面用) +9. フロントエンド: 在庫一覧に引当表示追加 +10. フロントエンド: 施肥計画編集に在庫参照追加 +11. フロントエンド: 散布確定画面 +12. フロントエンド: 施肥計画一覧に確定状態表示追加 -### やらないこと(将来フェーズ) +### やらないこと - 公式データ同期(FAMIC、農水省) - 別名辞書(MaterialAlias) - LLM 調査支援 -- 施肥計画画面への在庫参照表示 -- 在庫自動減算(施肥計画確定時) -- `MaterialStockSnapshot`(パフォーマンス最適化) +- 農薬散布計画の在庫連携 --- -## 2. バックエンド実装 +## 2. バックエンド: モデル変更 -### 2.1 アプリ作成 +### 2.1 StockTransaction の変更 (`backend/apps/materials/models.py`) -```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', '使用' @@ -237,520 +100,474 @@ class StockTransaction(models.Model): 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}' + DECREASE_TYPES = { + TransactionType.USE, + TransactionType.ADJUSTMENT_MINUS, + TransactionType.DISCARD, + } ``` -**注意**: `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 +class StockTransaction(models.Model): + class TransactionType(models.TextChoices): + PURCHASE = 'purchase', '入庫' + USE = 'use', '使用' + RESERVE = 'reserve', '引当' # ← 追加 + ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増' + ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減' + DISCARD = 'discard', '廃棄' + + INCREASE_TYPES = { + TransactionType.PURCHASE, + TransactionType.ADJUSTMENT_PLUS, + } + DECREASE_TYPES = { + TransactionType.USE, + TransactionType.RESERVE, # ← 追加 + TransactionType.ADJUSTMENT_MINUS, + TransactionType.DISCARD, + } +``` + +**フィールド追加**(既存フィールドの後に追加): + +```python + fertilization_plan = models.ForeignKey( + 'fertilizer.FertilizationPlan', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='stock_reservations', + verbose_name='施肥計画', + ) +``` + +### 2.2 FertilizationPlan の変更 (`backend/apps/fertilizer/models.py`) + +**フィールド追加**(既存フィールドの後に追加): + +```python + is_confirmed = models.BooleanField( + default=False, verbose_name='散布確定済み' + ) + confirmed_at = models.DateTimeField( + null=True, blank=True, verbose_name='散布確定日時' + ) +``` + +### 2.3 マイグレーション + +#### マイグレーション1: `backend/apps/materials/migrations/0002_stocktransaction_fertilization_plan.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) +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('fertilizer', '0004_fertilizationplan_calc_settings'), ('materials', '0001_initial'), + ('fertilizer', '0005_fertilizer_material'), ] operations = [ - # Step 1: nullable FK として追加 migrations.AddField( - model_name='fertilizer', - name='material', - field=models.OneToOneField( + model_name='stocktransaction', + name='fertilization_plan', + field=models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name='legacy_fertilizer', - to='materials.Material', - verbose_name='資材マスタ', + related_name='stock_reservations', + to='fertilizer.fertilizationplan', + verbose_name='施肥計画', ), ), - # Step 2: 既存データを移行 - migrations.RunPython( - create_materials_for_existing_fertilizers, - reverse_migration, - ), ] ``` -`apps/fertilizer/models.py` の `Fertilizer` クラスに追加: +注意: `TransactionType` の choices 変更はマイグレーション不要(Django は choices をDBレベルで強制しないため)。 + +#### マイグレーション2: `backend/apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py` + ```python -# 既存フィールドの後に追加 -material = models.OneToOneField( - 'materials.Material', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='legacy_fertilizer', - verbose_name='資材マスタ', -) +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fertilizer', '0005_fertilizer_material'), + ] + + operations = [ + migrations.AddField( + model_name='fertilizationplan', + name='is_confirmed', + field=models.BooleanField(default=False, verbose_name='散布確定済み'), + ), + migrations.AddField( + model_name='fertilizationplan', + name='confirmed_at', + field=models.DateTimeField( + blank=True, null=True, verbose_name='散布確定日時' + ), + ), + ] ``` -**注意**: `material` は `null=True` のままにする。施肥計画の既存ロジックには影響しない。 +--- -### 2.4 Serializer (`apps/materials/serializers.py`) +## 3. バックエンド: 引当ロジック + +### 3.1 引当の作成・解除ヘルパー関数 + +`backend/apps/materials/stock_service.py` を新規作成: ```python +from django.db import transaction +from .models import StockTransaction + + +@transaction.atomic +def create_reserves_for_plan(plan): + """施肥計画の全エントリについて引当トランザクションを作成する。 + 既存の引当は全削除してから再作成する(差分更新ではなく全置換)。 + """ + # 既存の引当を全削除 + StockTransaction.objects.filter( + fertilization_plan=plan, + transaction_type='reserve', + ).delete() + + # plan が確定済みなら引当を作らない(use が既にある) + if plan.is_confirmed: + return + + for entry in plan.entries.select_related('fertilizer__material'): + material = getattr(entry.fertilizer, 'material', None) + if material is None: + # Fertilizer.material が未連携の場合はスキップ + continue + StockTransaction.objects.create( + material=material, + transaction_type='reserve', + quantity=entry.bags, + occurred_on=plan.updated_at.date() if plan.updated_at else plan.created_at.date(), + note=f'施肥計画「{plan.name}」からの引当', + fertilization_plan=plan, + ) + + +@transaction.atomic +def delete_reserves_for_plan(plan): + """施肥計画に紐づく全引当トランザクションを削除する。""" + StockTransaction.objects.filter( + fertilization_plan=plan, + transaction_type='reserve', + ).delete() + + +@transaction.atomic +def confirm_spreading(plan, actual_entries): + """散布確定: 引当を削除し、実績数量で use トランザクションを作成する。 + + actual_entries: list of dict + [{"field_id": int, "fertilizer_id": int, "actual_bags": Decimal}, ...] + actual_bags=0 の行は引当解除のみ(use を作成しない) + """ + from apps.fertilizer.models import Fertilizer + from django.utils import timezone + + # 既存の引当を全削除 + delete_reserves_for_plan(plan) + + # 実績 > 0 の行について use トランザクションを作成 + today = timezone.now().date() + for entry_data in actual_entries: + actual_bags = entry_data['actual_bags'] + if actual_bags <= 0: + continue + + try: + fertilizer = Fertilizer.objects.select_related('material').get( + id=entry_data['fertilizer_id'] + ) + except Fertilizer.DoesNotExist: + continue + + material = getattr(fertilizer, 'material', None) + if material is None: + continue + + StockTransaction.objects.create( + material=material, + transaction_type='use', + quantity=actual_bags, + occurred_on=today, + note=f'施肥計画「{plan.name}」散布確定', + fertilization_plan=plan, + ) + + # 計画を確定済みに更新 + plan.is_confirmed = True + plan.confirmed_at = timezone.now() + plan.save(update_fields=['is_confirmed', 'confirmed_at']) +``` + +### 3.2 施肥計画 ViewSet の変更 (`backend/apps/fertilizer/views.py`) + +既存の `FertilizationPlanViewSet` に以下の変更を加える。 + +#### 保存時の引当自動作成 + +`perform_create` と `perform_update` をオーバーライドして、保存後に引当を作成する: + +```python +from apps.materials.stock_service import ( + create_reserves_for_plan, + delete_reserves_for_plan, + confirm_spreading as confirm_spreading_service, +) + +class FertilizationPlanViewSet(viewsets.ModelViewSet): + # ... 既存コード ... + + def perform_create(self, serializer): + instance = serializer.save() + create_reserves_for_plan(instance) + + def perform_update(self, serializer): + instance = serializer.save() + create_reserves_for_plan(instance) + + def perform_destroy(self, instance): + delete_reserves_for_plan(instance) + instance.delete() +``` + +#### 散布確定アクション + +```python +from rest_framework.decorators import action from decimal import Decimal -from django.db.models import Sum, Q -from rest_framework import serializers -from .models import Material, FertilizerProfile, PesticideProfile, StockTransaction +class FertilizationPlanViewSet(viewsets.ModelViewSet): + # ... 既存コード ... + + @action(detail=True, methods=['post'], url_path='confirm_spreading') + def confirm_spreading(self, request, pk=None): + plan = self.get_object() + + if plan.is_confirmed: + return Response( + {'detail': 'この計画は既に散布確定済みです。'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + entries_data = request.data.get('entries', []) + if not entries_data: + return Response( + {'detail': '実績データが空です。'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + actual_entries = [] + for entry in entries_data: + actual_entries.append({ + 'field_id': entry['field_id'], + 'fertilizer_id': entry['fertilizer_id'], + 'actual_bags': Decimal(str(entry.get('actual_bags', 0))), + }) + + confirm_spreading_service(plan, actual_entries) + + serializer = self.get_serializer(plan) + return Response(serializer.data) +``` + +### 3.3 施肥計画 Serializer の変更 (`backend/apps/fertilizer/serializers.py`) + +`FertilizationPlanSerializer`(読み取り用)に `is_confirmed` / `confirmed_at` を追加: + +```python +class FertilizationPlanSerializer(serializers.ModelSerializer): + # ... 既存フィールド ... + is_confirmed = serializers.BooleanField(read_only=True) + confirmed_at = serializers.DateTimeField(read_only=True) -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 + model = FertilizationPlan fields = [ - 'registration_no', 'formulation', 'usage_unit', - 'dilution_ratio', 'active_ingredient', 'category', + # ... 既存フィールド ..., + 'is_confirmed', 'confirmed_at', ] +``` +--- -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'] +## 4. バックエンド: 在庫集計 API の変更 +### 4.1 StockSummarySerializer の変更 (`backend/apps/materials/serializers.py`) +```python class StockSummarySerializer(serializers.Serializer): - """在庫集計(読み取り専用)""" material_id = serializers.IntegerField() name = serializers.CharField() material_type = serializers.CharField() material_type_display = serializers.CharField() + maker = 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) + reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加 + available_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加 last_transaction_date = serializers.DateField(allow_null=True) ``` -### 2.5 View (`apps/materials/views.py`) +### 4.2 StockSummaryView の変更 (`backend/apps/materials/views.py`) + +在庫集計のループ内で `reserved_stock` と `available_stock` を計算する: ```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 +for material in queryset: + transactions = list(material.stock_transactions.all()) + increase = sum( + txn.quantity for txn in transactions + if txn.transaction_type in StockTransaction.INCREASE_TYPES + ) + decrease = sum( + txn.quantity for txn in transactions + if txn.transaction_type in StockTransaction.DECREASE_TYPES + ) + reserved = sum( + txn.quantity for txn in transactions + if txn.transaction_type == 'reserve' + ) + last_date = max((txn.occurred_on for txn in transactions), default=None) -from .models import Material, StockTransaction -from .serializers import ( - MaterialReadSerializer, - MaterialWriteSerializer, - StockTransactionSerializer, - StockSummarySerializer, -) + current = increase - decrease # 引当込みの在庫(引当分は既に引かれている) + results.append({ + 'material_id': material.id, + 'name': material.name, + 'material_type': material.material_type, + 'material_type_display': material.get_material_type_display(), + 'maker': material.maker, + 'stock_unit': material.stock_unit, + 'stock_unit_display': material.get_stock_unit_display(), + 'is_active': material.is_active, + 'current_stock': current + reserved, # 引当を戻した「物理的な在庫」 + 'reserved_stock': reserved, # 引当中の数量 + 'available_stock': current, # 利用可能在庫(引当済み分を除く) + 'last_transaction_date': last_date, + }) +``` +**在庫計算の定義**: +- `current_stock`: 物理的に倉庫にある数量(入庫 - 使用 - 廃棄 ± 調整) +- `reserved_stock`: そのうち施肥計画で引き当てられている数量 +- `available_stock`: 新しい計画に使える数量(= current_stock - reserved_stock) -class MaterialViewSet(viewsets.ModelViewSet): - """資材マスタ CRUD""" +### 4.3 肥料在庫 API(施肥計画画面用) + +`backend/apps/materials/views.py` に追加: + +```python +class FertilizerStockView(generics.ListAPIView): + """施肥計画画面用: 肥料の在庫情報を返す""" 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') + queryset = Material.objects.filter( + material_type='fertilizer', + is_active=True, + ).prefetch_related('stock_transactions') results = [] - for mat in qs: - txns = mat.stock_transactions.all() + for material in queryset: + transactions = list(material.stock_transactions.all()) increase = sum( - t.quantity for t in txns - if t.transaction_type in ('purchase', 'adjustment_plus') + txn.quantity for txn in transactions + if txn.transaction_type in StockTransaction.INCREASE_TYPES ) decrease = sum( - t.quantity for t in txns - if t.transaction_type in ('use', 'adjustment_minus', 'discard') + txn.quantity for txn in transactions + if txn.transaction_type in StockTransaction.DECREASE_TYPES ) - last_date = max( - (t.occurred_on for t in txns), default=None + reserved = sum( + txn.quantity for txn in transactions + if txn.transaction_type == 'reserve' ) + current = increase - decrease 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, + 'material_id': material.id, + 'name': material.name, + 'material_type': material.material_type, + 'material_type_display': material.get_material_type_display(), + 'maker': material.maker, + 'stock_unit': material.stock_unit, + 'stock_unit_display': material.get_stock_unit_display(), + 'is_active': material.is_active, + 'current_stock': current + reserved, + 'reserved_stock': reserved, + 'available_stock': current, + 'last_transaction_date': max( + (t.occurred_on for t in transactions), default=None + ), }) serializer = StockSummarySerializer(results, many=True) return Response(serializer.data) ``` -### 2.6 URL (`apps/materials/urls.py`) +`backend/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'), + path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'), # ← 追加 ] ``` -### 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. フロントエンド実装 +## 5. フロントエンド: 型定義の変更 -### 3.1 型定義 (`frontend/src/types/index.ts` に追記) +### 5.1 StockTransaction 型に `reserve` 追加 (`frontend/src/types/index.ts`) +**変更前**: ```typescript -// ===== 資材管理 ===== +transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard'; +``` -export interface FertilizerProfile { - capacity_kg: string | null; - nitrogen_pct: string | null; - phosphorus_pct: string | null; - potassium_pct: string | null; -} +**変更後**: +```typescript +transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard'; +``` -export interface PesticideProfile { - registration_no: string; - formulation: string; - usage_unit: string; - dilution_ratio: string; - active_ingredient: string; - category: string; -} +### 5.2 StockSummary 型に引当フィールド追加 -export interface Material { - id: number; +**変更前**: +```typescript +export interface StockSummary { + 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; @@ -759,198 +576,255 @@ export interface StockSummary { } ``` -### 3.2 画面構成 - -``` -frontend/src/app/materials/ -├── page.tsx # 在庫一覧(メイン画面) -├── masters/ -│ └── page.tsx # 資材マスタ管理 -└── _components/ - ├── StockOverview.tsx # 在庫一覧コンポーネント - ├── StockTransactionForm.tsx # 入出庫登録フォーム(モーダルまたはインライン) - └── MaterialForm.tsx # 資材登録・編集フォーム +**変更後**: +```typescript +export interface StockSummary { + material_id: number; + name: string; + material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other'; + material_type_display: string; + maker: string; + stock_unit: string; + stock_unit_display: string; + is_active: boolean; + current_stock: string; + reserved_stock: string; // ← 追加 + available_stock: string; // ← 追加 + last_transaction_date: string | null; +} ``` -### 3.3 在庫一覧画面 (`/materials` → `page.tsx`) +### 5.3 FertilizationPlan 型に確定フィールド追加 -**機能:** -- 全資材の現在庫を一覧表示 -- `material_type` でフィルタ(タブ: 全て / 肥料 / 農薬 / その他) -- 「入出庫登録」ボタン → モーダルで入出庫フォームを開く -- 「資材マスタ」ボタン → `/materials/masters` へ遷移 +既存の `FertilizationPlan` インターフェースに追加: -**テーブル列:** -| 資材名 | 種別 | メーカー | 現在庫 | 単位 | 最終入出庫日 | 操作 | - -**操作列:** -- 「入庫」ボタン(クリック → 入出庫フォームをプリセット `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'; - -// メニューボタン(「分配計画」の後に追加) - +```typescript +export interface FertilizationPlan { + // ... 既存フィールド ... + is_confirmed: boolean; // ← 追加 + confirmed_at: string | null; // ← 追加 +} ``` --- -## 4. API エンドポイント一覧 +## 6. フロントエンド: 画面変更 + +### 6.1 在庫一覧の引当表示 (`frontend/src/app/materials/_components/StockOverview.tsx`) + +現在庫の表示を変更: + +**変更前**: +``` +現在庫: 18 +``` + +**変更後**: +``` +在庫 18袋(引当 12袋)/ 利用可能 6袋 +``` + +引当が0の場合は引当表示を省略する。 + +### 6.2 施肥計画編集画面の在庫参照 (`frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx`) + +施肥計画の編集画面(マトリクス表)で、肥料列ヘッダーに在庫情報を表示する。 + +**追加表示**(肥料名の下に小さく): +``` +仁井田米有機 +在庫 18袋 / 計画計 24袋 +``` + +計画合計が在庫を超える場合は赤文字で「不足 6袋」を表示する。 + +**データ取得**: ページ読み込み時に `GET /api/materials/fertilizer-stock/` を呼び、 +`Fertilizer.material` の OneToOne 経由で material_id と紐づける。 + +紐づけロジック: +1. `GET /api/fertilizer/fertilizers/` で肥料一覧を取得(既存) +2. `GET /api/materials/materials/?material_type=fertilizer` で Material 一覧を取得 +3. `Fertilizer.name` と `Material.name` を突き合わせる(同名で作成されているため一致する) + +または、Fertilizer の serializer に `material_id` を追加して直接紐づける(推奨)。 + +**Fertilizer serializer への追加**(`backend/apps/fertilizer/serializers.py`): + +```python +class FertilizerSerializer(serializers.ModelSerializer): + material_id = serializers.IntegerField(source='material.id', read_only=True, default=None) + + class Meta: + model = Fertilizer + fields = [ + # ... 既存フィールド ..., + 'material_id', + ] +``` + +### 6.3 施肥計画一覧の確定状態表示 (`frontend/src/app/fertilizer/page.tsx`) + +各計画行に確定状態を表示: + +- 未確定: 通常表示 + 「散布確定」ボタン +- 確定済み: 背景色変更(例: 薄い青)+ 「確定済み ✓」バッジ + 確定日時 + +### 6.4 散布確定画面 + +**実装方法**: モーダルまたは専用ページ。施肥計画一覧の「散布確定」ボタンから起動。 + +**画面構成**: + +``` +┌─ 散布確定: 「計画名」──────────────────────────────┐ +│ │ +│ 肥料: 仁井田米有機 │ +│ ┌─────────────┬──────┬──────────┐ │ +│ │ 圃場 │ 計画 │ 実績 │ │ +│ ├─────────────┼──────┼──────────┤ │ +│ │ 上の田 │ 3袋 │ [ 3 ] │ │ +│ │ 下の田 │ 4袋 │ [ 3.5 ] │ │ +│ │ 山の畑 │ 2袋 │ [ 0 ] │ │ +│ └─────────────┴──────┴──────────┘ │ +│ │ +│ 肥料: 土佐勤農党 │ +│ ┌─────────────┬──────┬──────────┐ │ +│ │ 圃場 │ 計画 │ 実績 │ │ +│ ├─────────────┼──────┼──────────┤ │ +│ │ ... │ ... │ [ ... ] │ │ +│ └─────────────┴──────┴──────────┘ │ +│ │ +│ [キャンセル] [一括確定] │ +└─────────────────────────────────────────────────────┘ +``` + +**動作**: +1. 施肥計画のエントリを肥料ごとにグループ化して表示 +2. 「実績」列は計画値がプリセットされた数値入力欄 +3. 修正が必要な行だけ数値を変更する +4. 実績を0にした行は「未散布」として引当解除される +5. 「一括確定」で `POST /api/fertilizer/plans/{id}/confirm_spreading/` を呼ぶ + +**API リクエスト**: +```json +{ + "entries": [ + {"field_id": 1, "fertilizer_id": 3, "actual_bags": 3.0}, + {"field_id": 2, "fertilizer_id": 3, "actual_bags": 3.5}, + {"field_id": 3, "fertilizer_id": 3, "actual_bags": 0} + ] +} +``` + +--- + +## 7. API エンドポイント一覧(Phase 1.5 で追加・変更) + +### 新規 | メソッド | パス | 認証 | 説明 | |----------|------|------|------| -| 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=`) | +| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | JWT | 散布確定(reserve→use変換) | +| GET | `/api/materials/fertilizer-stock/` | JWT | 肥料在庫一覧(施肥計画画面用) | + +### 変更 + +| メソッド | パス | 変更内容 | +|----------|------|----------| +| POST/PUT | `/api/fertilizer/plans/` | 保存後に reserve 自動作成 | +| DELETE | `/api/fertilizer/plans/{id}/` | 削除前に reserve 自動削除 | +| GET | `/api/fertilizer/plans/` | レスポンスに `is_confirmed`, `confirmed_at` 追加 | +| GET | `/api/fertilizer/fertilizers/` | レスポンスに `material_id` 追加 | +| GET | `/api/materials/stock-summary/` | レスポンスに `reserved_stock`, `available_stock` 追加 | --- -## 5. 実装順序(厳守) +## 8. 実装順序(厳守) -### 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 1: バックエンド — モデル・マイグレーション +1. `apps/materials/models.py` に `reserve` タイプ追加、`DECREASE_TYPES` 更新、`fertilization_plan` FK 追加 +2. `apps/fertilizer/models.py` に `is_confirmed`, `confirmed_at` 追加 +3. `apps/materials/migrations/0002_stocktransaction_fertilization_plan.py` 作成 +4. `apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py` 作成 -### Step 2: 既存 Fertilizer 連携 -7. `apps/fertilizer/models.py` に `material` フィールド追加 -8. `apps/fertilizer/migrations/0005_fertilizer_material.py` 作成(データ移行込み) -9. `python manage.py migrate` +### Step 2: バックエンド — ロジック・API +5. `apps/materials/stock_service.py` 作成(引当作成・解除・散布確定ヘルパー) +6. `apps/fertilizer/views.py` の `FertilizationPlanViewSet` に `perform_create`, `perform_update`, `perform_destroy` オーバーライド追加 +7. `apps/fertilizer/views.py` に `confirm_spreading` アクション追加 +8. `apps/fertilizer/serializers.py` に `is_confirmed`, `confirmed_at` 追加 +9. `apps/fertilizer/serializers.py` の `FertilizerSerializer` に `material_id` 追加 +10. `apps/materials/serializers.py` の `StockSummarySerializer` に `reserved_stock`, `available_stock` 追加 +11. `apps/materials/views.py` の `StockSummaryView` で引当集計を追加 +12. `apps/materials/views.py` に `FertilizerStockView` 追加 +13. `apps/materials/urls.py` に `fertilizer-stock/` パス追加 -### 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` にメニュー追加 +### Step 3: フロントエンド +14. `types/index.ts` に `reserve` タイプ追加、`StockSummary` に引当フィールド追加、`FertilizationPlan` に確定フィールド追加 +15. `app/materials/_components/StockOverview.tsx` に引当表示追加 +16. `app/materials/page.tsx` の `StockTransactionForm` に `reserve` オプション追加(手動引当は不要なら省略可) +17. `app/fertilizer/_components/FertilizerEditPage.tsx` に在庫参照表示追加 +18. `app/fertilizer/page.tsx` に確定状態表示・散布確定ボタン追加 +19. `app/fertilizer/_components/ConfirmSpreadingModal.tsx` 新規作成(散布確定モーダル) --- -## 6. テスト確認項目 +## 9. テスト確認項目 ### バックエンド -- [ ] `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 0002, fertilizer 0006) +- [ ] 施肥計画を保存すると、各エントリに対応する reserve トランザクションが作成される +- [ ] 施肥計画を更新すると、古い reserve が削除され新しい reserve が作成される +- [ ] 施肥計画を削除すると、reserve が全て削除される +- [ ] `GET /api/materials/stock-summary/` で `reserved_stock` と `available_stock` が返る +- [ ] 入庫10 → 引当3 → `current_stock=10`, `reserved_stock=3`, `available_stock=7` +- [ ] `POST /api/fertilizer/plans/{id}/confirm_spreading/` で reserve が use に変換される +- [ ] 確定済み計画に再度 confirm_spreading すると 400 エラー +- [ ] actual_bags=0 の行は reserve 削除のみ(use は作成しない) +- [ ] `Fertilizer.material` が null の Fertilizer は引当をスキップする +- [ ] 既存の施肥計画 CRUD(作成・編集・削除・PDF)が壊れていない ### フロントエンド -- [ ] `/materials` で在庫一覧が表示される -- [ ] 入出庫登録モーダルが開き、保存後に一覧が更新される -- [ ] `/materials/masters` で資材マスタのインライン編集ができる -- [ ] Navbar の「在庫管理」から遷移できる -- [ ] 施肥計画画面(`/fertilizer`)が変更なく動作する +- [ ] 在庫一覧に引当数量と利用可能在庫が表示される +- [ ] 施肥計画編集画面に肥料ごとの在庫情報が表示される +- [ ] 施肥計画一覧に確定状態(未確定/確定済み)が表示される +- [ ] 散布確定モーダルが開き、計画値がプリセットされる +- [ ] 実績を修正して一括確定できる +- [ ] 確定後、計画が「確定済み」表示に変わる +- [ ] 確定済みの計画には「散布確定」ボタンが表示されない --- -## 7. 既存コードへの変更一覧(影響範囲) +## 10. 既存コードへの変更一覧(影響範囲) | ファイル | 変更内容 | |----------|----------| -| `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` | 「在庫管理」メニュー追加 | - -上記以外の既存ファイルには**一切触らない**。 +| `backend/apps/materials/models.py` | `StockTransaction` に `reserve` タイプ・`fertilization_plan` FK 追加 | +| `backend/apps/materials/serializers.py` | `StockSummarySerializer` に `reserved_stock`・`available_stock` 追加 | +| `backend/apps/materials/views.py` | `StockSummaryView` 集計変更、`FertilizerStockView` 追加 | +| `backend/apps/materials/urls.py` | `fertilizer-stock/` パス追加 | +| `backend/apps/materials/stock_service.py` | **新規作成** — 引当ロジック | +| `backend/apps/materials/migrations/0002_...py` | **新規作成** — fertilization_plan FK | +| `backend/apps/fertilizer/models.py` | `FertilizationPlan` に `is_confirmed`・`confirmed_at` 追加 | +| `backend/apps/fertilizer/views.py` | `perform_create/update/destroy` オーバーライド、`confirm_spreading` アクション追加 | +| `backend/apps/fertilizer/serializers.py` | `is_confirmed`・`confirmed_at`・`material_id` 追加 | +| `backend/apps/fertilizer/migrations/0006_...py` | **新規作成** — is_confirmed, confirmed_at | +| `frontend/src/types/index.ts` | `reserve` タイプ追加、引当フィールド追加、確定フィールド追加 | +| `frontend/src/app/materials/_components/StockOverview.tsx` | 引当表示追加 | +| `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` | 在庫参照表示追加 | +| `frontend/src/app/fertilizer/page.tsx` | 確定状態表示・散布確定ボタン追加 | +| `frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx` | **新規作成** — 散布確定モーダル | --- -## 8. 参照すべき既存コード(実装パターンの手本) +## 11. 参照すべき既存コード(実装パターンの手本) | 目的 | 参照先 | |------|--------| -| 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` | +| 施肥計画 ViewSet(perform_create の追加先) | `backend/apps/fertilizer/views.py` | +| 施肥計画 Serializer(フィールド追加先) | `backend/apps/fertilizer/serializers.py` | +| 施肥計画の @action パターン(PDF アクション) | `backend/apps/fertilizer/views.py` の `pdf` アクション | +| 在庫集計ロジック | `backend/apps/materials/views.py` の `StockSummaryView` | +| 施肥計画編集画面(マトリクス表) | `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` | +| 施肥計画一覧画面 | `frontend/src/app/fertilizer/page.tsx` | +| モーダルパターン | `frontend/src/app/materials/_components/StockTransactionForm.tsx` | +| 在庫一覧コンポーネント | `frontend/src/app/materials/_components/StockOverview.tsx` | diff --git a/改善案/在庫管理機能実装案.md b/改善案/在庫管理機能実装案.md index 78ee1ef..275d4de 100644 --- a/改善案/在庫管理機能実装案.md +++ b/改善案/在庫管理機能実装案.md @@ -1578,6 +1578,321 @@ alias 例: ### 22.3 実装フェーズの整理 -- セクション1〜16: Phase 1(初期実装) -- セクション17〜21: Phase 2(公式データ同期、alias辞書、LLM調査支援) -- CODEX への初回作業指示は Phase 1 のみを対象とする +- セクション1〜16: Phase 1(初期実装)→ 実装済み +- セクション17〜21: Phase 3(公式データ同期、alias辞書、LLM調査支援) +- セクション23: Phase 1.5(施肥計画連携・引当機能)→ 次の実装対象 + +--- + +## 23. 施肥計画連携: 引当(reserve)・散布確定フロー + +### 23.1 背景と要件 + +施肥計画を立てるとき、現在の利用可能在庫を見ながら計画を立てたい。 +しかし「計画確定 = 即在庫消費」にすると、実際の散布量が計画と異なった場合に +差異調整が毎回必要になる。 + +そこで「引当 → 散布確定」の2段階方式を採用する。 + +要件: + +- 施肥計画確定時に、計画数量を「引当」として在庫から仮押さえする +- 実際に散布した後、実績数量で「使用確定」に変換する +- 差分は自動で在庫に戻す +- 計画削除・変更時は引当を自動解除する +- 施肥計画画面で「利用可能在庫」を表示し、計画策定の判断材料にする + +### 23.2 在庫の3つの状態 + +``` +総在庫 = 入庫 + 調整増 - 使用確定 - 調整減 - 廃棄 - 引当中 +利用可能在庫 = 総在庫のうち引当を除いた分 + = 入庫 + 調整増 - 使用確定 - 調整減 - 廃棄 +引当中 = reserve トランザクションの合計(未確定の施肥計画分) +``` + +施肥計画画面では「利用可能在庫」を表示する。 +在庫一覧画面では「総在庫(引当中 X を含む)」のように表示する。 + +### 23.3 StockTransaction への変更 + +#### transaction_type に `reserve` を追加 + +```python +class TransactionType(models.TextChoices): + PURCHASE = 'purchase', '入庫' + USE = 'use', '使用' # 散布確定済み + RESERVE = 'reserve', '引当' # ← 新規追加 + ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増' + ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減' + DISCARD = 'discard', '廃棄' +``` + +`reserve` は `use` と同じく在庫を減少させる方向で集計する。 + +#### fertilization_plan FK を追加 + +```python +fertilization_plan = models.ForeignKey( + 'fertilizer.FertilizationPlan', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='stock_reservations', + verbose_name='施肥計画', +) +``` + +当初除外した `reference_type` / `reference_id`(Generic FK)ではなく、 +施肥計画への明示的な FK を採用する。理由: + +- 現時点で在庫連携が必要なのは施肥計画のみ +- Generic FK は Django の管理上も ORM 上も扱いにくい +- 将来、農薬散布計画が加わった場合は `pesticide_plan` FK を追加すればよい + +#### INCREASE_TYPES / DECREASE_TYPES の更新 + +```python +INCREASE_TYPES = { + TransactionType.PURCHASE, + TransactionType.ADJUSTMENT_PLUS, +} +DECREASE_TYPES = { + TransactionType.USE, + TransactionType.RESERVE, # ← 追加 + TransactionType.ADJUSTMENT_MINUS, + TransactionType.DISCARD, +} +``` + +### 23.4 操作フロー + +#### フロー1: 施肥計画の確定(引当作成) + +``` +ユーザー: 施肥計画を保存 +システム: 計画内の各 FertilizationEntry について + → 対応する Fertilizer.material を特定 + → StockTransaction(type='reserve', material=..., + quantity=bags, fertilization_plan=plan) を作成 + → 利用可能在庫が減少 +``` + +注意: `Fertilizer.material` が null の場合(未連携の古いデータ)は引当を作成しない。 + +#### フロー2: 散布確定(引当 → 使用変換) + +``` +ユーザー: 施肥計画画面で「散布確定」ボタンを押す +システム: 確定前の調整画面を表示 + + ┌─────────────┬──────┬──────┬────────┐ + │ 圃場 │ 計画 │ 実績 │ 状態 │ + ├─────────────┼──────┼──────┼────────┤ + │ 上の田 │ 3袋 │ 3 │ ✓ │ + │ 下の田 │ 4袋 │ 3.5 │ 修正 │ + │ 山の畑 │ 2袋 │ 0 │ 未散布 │ + └─────────────┴──────┴──────┴────────┘ + +ユーザー: 各行の実績を確認・修正し、「一括確定」を押す +システム: + - 実績 > 0 の行: reserve を削除 → use(quantity=実績) を作成 + - 実績 = 0 の行: reserve を削除(引当解除、在庫に戻る) + - 差分は自動計算(ユーザーの追加操作不要) +``` + +普段は計画値がそのまま実績にプリセットされるので、何も修正せず「一括確定」するだけ。 +アクシデントがあった圃場だけ数量を修正する。 + +#### フロー3: 計画の削除 + +``` +ユーザー: 施肥計画を削除 +システム: その計画に紐づく全 reserve トランザクションを削除 + → 引当が解除され、在庫に戻る +``` + +#### フロー4: 計画の変更(エントリ追加・削除・数量変更) + +``` +ユーザー: 施肥計画を編集・保存 +システム: その計画に紐づく既存 reserve を全削除 + → 新しいエントリに基づいて reserve を再作成 +``` + +注意: reserve の差分更新は複雑になるため、「全削除 → 再作成」方式を推奨する。 + +### 23.5 施肥計画画面の在庫表示 + +施肥計画の編集画面(マトリクス表)で、肥料ごとの在庫情報を表示する。 + +表示位置: 肥料列ヘッダーの下、または肥料名の横 + +表示例: + +``` +仁井田米有機 +在庫 18袋 / 計画計 24袋 / 不足 6袋 +``` + +API: + +- 既存の `GET /api/materials/stock-summary/` を流用 +- または施肥計画用の専用エンドポイントで肥料在庫のみ返す + +### 23.6 在庫一覧画面の表示変更 + +在庫一覧(`/materials`)では、引当中の数量も表示する。 + +表示例: + +``` +仁井田米有機: 在庫 18袋(うち引当 12袋)/ 利用可能 6袋 +``` + +StockSummary API のレスポンスに以下を追加: + +```json +{ + "material_id": 3, + "name": "仁井田米有機", + "current_stock": "18.000", + "reserved_stock": "12.000", + "available_stock": "6.000", + "last_transaction_date": "2026-03-14" +} +``` + +### 23.7 施肥計画の状態管理 + +施肥計画に散布確定状態を持たせる。 + +FertilizationPlan に以下のフィールドを追加: + +```python +is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み') +confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時') +``` + +状態遷移: + +- `is_confirmed=False`: 計画中(編集可能、引当あり) +- `is_confirmed=True`: 散布確定済み(引当 → 使用に変換済み) + +確定済みの計画は: + +- 編集不可(またはバナーで注意喚起して編集許可) +- 再確定はしない +- 削除は可能(use トランザクションはそのまま残す) + +### 23.8 API 変更・追加 + +#### 既存 API への影響 + +| API | 変更内容 | +|-----|----------| +| `POST/PUT /api/fertilizer/plans/` | 保存時に reserve 自動作成(全削除→再作成) | +| `DELETE /api/fertilizer/plans/{id}/` | 削除時に reserve 自動削除 | + +#### 新規 API + +| メソッド | パス | 説明 | +|----------|------|------| +| `POST` | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(reserve→use変換) | +| `GET` | `/api/materials/fertilizer-stock/` | 肥料在庫一覧(施肥計画画面用、利用可能在庫含む) | + +散布確定 API のリクエスト例: + +```json +{ + "entries": [ + {"field_id": 1, "fertilizer_id": 3, "actual_bags": 3.0}, + {"field_id": 2, "fertilizer_id": 3, "actual_bags": 3.5}, + {"field_id": 3, "fertilizer_id": 3, "actual_bags": 0} + ] +} +``` + +`actual_bags=0` の行は引当解除として処理する。 + +### 23.9 マイグレーション計画 + +#### materials 0002: reserve タイプ追加 + fertilization_plan FK + +```python +# StockTransaction に以下を追加: +# - transaction_type の choices に 'reserve' を追加(choices 変更はマイグレーション不要) +# - fertilization_plan FK を追加 +migrations.AddField( + model_name='stocktransaction', + name='fertilization_plan', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='stock_reservations', + to='fertilizer.fertilizationplan', + verbose_name='施肥計画', + ), +) +``` + +#### fertilizer 0006: is_confirmed, confirmed_at 追加 + +```python +migrations.AddField( + model_name='fertilizationplan', + name='is_confirmed', + field=models.BooleanField(default=False, verbose_name='散布確定済み'), +), +migrations.AddField( + model_name='fertilizationplan', + name='confirmed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='散布確定日時'), +), +``` + +### 23.10 フロントエンド変更 + +#### 施肥計画一覧 (`/fertilizer`) + +- 各計画に確定状態アイコンを追加(未確定 / 確定済み) +- 確定済みの計画は背景色で視覚的に区別 + +#### 施肥計画編集 (`/fertilizer/[id]/edit`) + +- 肥料列ヘッダーに在庫情報を表示 +- 確定済みの場合は編集不可バナーを表示 + +#### 散布確定画面(新規) + +- 施肥計画一覧から「散布確定」ボタンで遷移 or モーダル +- 圃場×肥料のマトリクスに「計画」と「実績」列を並べて表示 +- 計画値がプリセットされ、修正が必要な行だけ編集する +- 「一括確定」ボタンで POST + +#### 在庫一覧 (`/materials`) + +- `reserved_stock` と `available_stock` を表示に追加 + +### 23.11 実装順序 + +1. StockTransaction に `reserve` タイプ追加 + `fertilization_plan` FK(マイグレーション) +2. FertilizationPlan に `is_confirmed` / `confirmed_at` 追加(マイグレーション) +3. StockSummary API に `reserved_stock` / `available_stock` 追加 +4. 施肥計画の保存処理に reserve 自動作成ロジック追加 +5. 施肥計画の削除処理に reserve 自動削除ロジック追加 +6. 散布確定 API(`confirm_spreading`)実装 +7. 肥料在庫 API(`fertilizer-stock`)実装 +8. フロントエンド: 在庫一覧に引当表示追加 +9. フロントエンド: 施肥計画編集に在庫参照追加 +10. フロントエンド: 散布確定画面実装 +11. フロントエンド: 施肥計画一覧に確定状態表示追加 + +### 23.12 この設計のメリット + +- 計画作成時に利用可能在庫を見ながら計画を立てられる +- 普段は一括確定するだけなので操作は軽い +- アクシデント時は個別に実績数量を修正できる +- 計画の削除・変更で引当が自動解除されるため、在庫が宙に浮かない +- 在庫一覧で「引当中」が見えるため、在庫状況が透明