- 在庫管理機能実装案.md: セクション23(引当・散布確定ワークフロー)を追加 - CODEX.md: Phase 1完了を受け、Phase 1.5実装指示に全面書き換え Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
831 lines
32 KiB
Markdown
831 lines
32 KiB
Markdown
# CODEX 実装指示書: 施肥計画連携・引当機能(Phase 1.5)
|
||
|
||
> 作成日: 2026-03-14
|
||
> 対象: `keinasystem_t02`
|
||
> 設計案: `改善案/在庫管理機能実装案.md`(セクション23が対象)
|
||
> 前提: Phase 1(セクション1〜16)は実装済み。`apps/materials` が稼働中。
|
||
|
||
---
|
||
|
||
## 0. 実装の前提と絶対ルール
|
||
|
||
### 現在のプロジェクト構造(Phase 1 実装済み)
|
||
|
||
```
|
||
keinasystem_t02/
|
||
├── backend/
|
||
│ ├── keinasystem/
|
||
│ │ ├── settings.py # apps.materials 登録済み
|
||
│ │ └── urls.py # /api/materials/ 登録済み
|
||
│ └── apps/
|
||
│ ├── 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 # Material, StockTransaction, StockSummary 定義済み
|
||
├── lib/api.ts # axios インスタンス(変更不要)
|
||
├── components/
|
||
│ └── Navbar.tsx # 在庫管理メニュー追加済み
|
||
└── app/
|
||
├── 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 <token>`)
|
||
- Docker: `docker compose exec backend python manage.py ...`
|
||
|
||
### 絶対ルール
|
||
|
||
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.5)
|
||
|
||
### やること
|
||
|
||
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 調査支援
|
||
- 農薬散布計画の在庫連携
|
||
|
||
---
|
||
|
||
## 2. バックエンド: モデル変更
|
||
|
||
### 2.1 StockTransaction の変更 (`backend/apps/materials/models.py`)
|
||
|
||
**現在のコード**(変更が必要な箇所のみ抜粋):
|
||
|
||
```python
|
||
class StockTransaction(models.Model):
|
||
class TransactionType(models.TextChoices):
|
||
PURCHASE = 'purchase', '入庫'
|
||
USE = 'use', '使用'
|
||
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
||
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
||
DISCARD = 'discard', '廃棄'
|
||
|
||
INCREASE_TYPES = {
|
||
TransactionType.PURCHASE,
|
||
TransactionType.ADJUSTMENT_PLUS,
|
||
}
|
||
DECREASE_TYPES = {
|
||
TransactionType.USE,
|
||
TransactionType.ADJUSTMENT_MINUS,
|
||
TransactionType.DISCARD,
|
||
}
|
||
```
|
||
|
||
**変更後**:
|
||
|
||
```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
|
||
import django.db.models.deletion
|
||
from django.db import migrations, models
|
||
|
||
|
||
class Migration(migrations.Migration):
|
||
|
||
dependencies = [
|
||
('materials', '0001_initial'),
|
||
('fertilizer', '0005_fertilizer_material'),
|
||
]
|
||
|
||
operations = [
|
||
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='施肥計画',
|
||
),
|
||
),
|
||
]
|
||
```
|
||
|
||
注意: `TransactionType` の choices 変更はマイグレーション不要(Django は choices をDBレベルで強制しないため)。
|
||
|
||
#### マイグレーション2: `backend/apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py`
|
||
|
||
```python
|
||
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='散布確定日時'
|
||
),
|
||
),
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
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 Meta:
|
||
model = FertilizationPlan
|
||
fields = [
|
||
# ... 既存フィールド ...,
|
||
'is_confirmed', 'confirmed_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)
|
||
```
|
||
|
||
### 4.2 StockSummaryView の変更 (`backend/apps/materials/views.py`)
|
||
|
||
在庫集計のループ内で `reserved_stock` と `available_stock` を計算する:
|
||
|
||
```python
|
||
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)
|
||
|
||
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)
|
||
|
||
### 4.3 肥料在庫 API(施肥計画画面用)
|
||
|
||
`backend/apps/materials/views.py` に追加:
|
||
|
||
```python
|
||
class FertilizerStockView(generics.ListAPIView):
|
||
"""施肥計画画面用: 肥料の在庫情報を返す"""
|
||
permission_classes = [IsAuthenticated]
|
||
serializer_class = StockSummarySerializer
|
||
|
||
def get_queryset(self):
|
||
return None
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
queryset = Material.objects.filter(
|
||
material_type='fertilizer',
|
||
is_active=True,
|
||
).prefetch_related('stock_transactions')
|
||
|
||
results = []
|
||
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'
|
||
)
|
||
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': max(
|
||
(t.occurred_on for t in transactions), default=None
|
||
),
|
||
})
|
||
|
||
serializer = StockSummarySerializer(results, many=True)
|
||
return Response(serializer.data)
|
||
```
|
||
|
||
`backend/apps/materials/urls.py` に追加:
|
||
|
||
```python
|
||
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'), # ← 追加
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 5. フロントエンド: 型定義の変更
|
||
|
||
### 5.1 StockTransaction 型に `reserve` 追加 (`frontend/src/types/index.ts`)
|
||
|
||
**変更前**:
|
||
```typescript
|
||
transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||
```
|
||
|
||
**変更後**:
|
||
```typescript
|
||
transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||
```
|
||
|
||
### 5.2 StockSummary 型に引当フィールド追加
|
||
|
||
**変更前**:
|
||
```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;
|
||
last_transaction_date: string | null;
|
||
}
|
||
```
|
||
|
||
**変更後**:
|
||
```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;
|
||
}
|
||
```
|
||
|
||
### 5.3 FertilizationPlan 型に確定フィールド追加
|
||
|
||
既存の `FertilizationPlan` インターフェースに追加:
|
||
|
||
```typescript
|
||
export interface FertilizationPlan {
|
||
// ... 既存フィールド ...
|
||
is_confirmed: boolean; // ← 追加
|
||
confirmed_at: string | null; // ← 追加
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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 で追加・変更)
|
||
|
||
### 新規
|
||
|
||
| メソッド | パス | 認証 | 説明 |
|
||
|----------|------|------|------|
|
||
| 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` 追加 |
|
||
|
||
---
|
||
|
||
## 8. 実装順序(厳守)
|
||
|
||
### 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: バックエンド — ロジック・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: フロントエンド
|
||
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` 新規作成(散布確定モーダル)
|
||
|
||
---
|
||
|
||
## 9. テスト確認項目
|
||
|
||
### バックエンド
|
||
- [ ] マイグレーション適用成功(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)が壊れていない
|
||
|
||
### フロントエンド
|
||
- [ ] 在庫一覧に引当数量と利用可能在庫が表示される
|
||
- [ ] 施肥計画編集画面に肥料ごとの在庫情報が表示される
|
||
- [ ] 施肥計画一覧に確定状態(未確定/確定済み)が表示される
|
||
- [ ] 散布確定モーダルが開き、計画値がプリセットされる
|
||
- [ ] 実績を修正して一括確定できる
|
||
- [ ] 確定後、計画が「確定済み」表示に変わる
|
||
- [ ] 確定済みの計画には「散布確定」ボタンが表示されない
|
||
|
||
---
|
||
|
||
## 10. 既存コードへの変更一覧(影響範囲)
|
||
|
||
| ファイル | 変更内容 |
|
||
|----------|----------|
|
||
| `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` | **新規作成** — 散布確定モーダル |
|
||
|
||
---
|
||
|
||
## 11. 参照すべき既存コード(実装パターンの手本)
|
||
|
||
| 目的 | 参照先 |
|
||
|------|--------|
|
||
| 施肥計画 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` |
|