在庫管理機能実装案をレビュー反映し、CODEX実装指示書を追加
- StockTransaction から冗長フィールド除外(unit, reference_type/id, created_by, inventory_count) - フロントエンド画面構成を変更(入出庫登録をモーダル化、マスタ管理をタブ統合) - レビュー記録セクション22を追加 - CODEX.md: Phase 1 実装指示書を作成(モデル・API・画面の詳細仕様) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
956
CODEX.md
Normal file
956
CODEX.md
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
# CODEX 実装指示書: 在庫管理機能(Phase 1)
|
||||||
|
|
||||||
|
> 作成日: 2026-03-14
|
||||||
|
> 対象: `keinasystem_t02`
|
||||||
|
> 設計案: `改善案/在庫管理機能実装案.md`(セクション1〜16が対象。17〜21は将来フェーズ)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 実装の前提と絶対ルール
|
||||||
|
|
||||||
|
### プロジェクト構造
|
||||||
|
|
||||||
|
```
|
||||||
|
keinasystem_t02/
|
||||||
|
├── backend/
|
||||||
|
│ ├── keinasystem/
|
||||||
|
│ │ ├── settings.py # INSTALLED_APPS にアプリ登録
|
||||||
|
│ │ └── urls.py # ルートURL登録
|
||||||
|
│ └── apps/
|
||||||
|
│ ├── fields/ # 圃場管理(Field モデル)
|
||||||
|
│ ├── plans/ # 作付け計画(Crop, Variety モデル)
|
||||||
|
│ ├── fertilizer/ # 施肥計画(Fertilizer, FertilizationPlan 等)
|
||||||
|
│ └── materials/ # ← 新規作成
|
||||||
|
└── frontend/
|
||||||
|
└── src/
|
||||||
|
├── types/index.ts # 型定義(ここに追記)
|
||||||
|
├── lib/api.ts # axios インスタンス(変更不要)
|
||||||
|
├── components/
|
||||||
|
│ └── Navbar.tsx # ナビゲーション(メニュー追加)
|
||||||
|
└── app/
|
||||||
|
└── materials/ # ← 新規作成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技術スタック
|
||||||
|
|
||||||
|
- Backend: Django 5.2 + Django REST Framework + PostgreSQL 16
|
||||||
|
- Frontend: Next.js 14 (App Router) + TypeScript strict + Tailwind CSS
|
||||||
|
- Docker: `docker compose exec backend python manage.py ...`
|
||||||
|
|
||||||
|
### 絶対ルール
|
||||||
|
|
||||||
|
1. **既存の `apps/fertilizer/` のモデル・API・画面を壊さない**
|
||||||
|
2. **`Fertilizer` モデルは改名・削除しない**(本番稼働中)
|
||||||
|
3. **`FertilizationEntry → Fertilizer` の FK は変更しない**
|
||||||
|
4. **マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない**
|
||||||
|
5. **フロントエンドでは `alert()` / `confirm()` を使わない**(インラインバナーで表示)
|
||||||
|
6. **API エンドポイントは複数形** (`/api/materials/`, NOT `/api/material/`)
|
||||||
|
7. **Django のベストプラクティスに従う**(ViewSet, Serializer, Router パターン)
|
||||||
|
8. **TypeScript strict mode に従う**
|
||||||
|
9. **Next.js 14 では `params` は通常のオブジェクト**(`use(params)` は使わない)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 実装スコープ(Phase 1 のみ)
|
||||||
|
|
||||||
|
### やること
|
||||||
|
|
||||||
|
1. `apps/materials` Django アプリを新規作成
|
||||||
|
2. `Material` モデル(共通資材マスタ)
|
||||||
|
3. `FertilizerProfile` モデル(肥料詳細、Material と 1:1)
|
||||||
|
4. `PesticideProfile` モデル(農薬詳細、Material と 1:1)
|
||||||
|
5. `StockTransaction` モデル(入出庫履歴)
|
||||||
|
6. 既存 `Fertilizer` に `material` OneToOneField を追加(データ移行込み)
|
||||||
|
7. 資材マスタ CRUD API
|
||||||
|
8. 在庫履歴 API
|
||||||
|
9. 在庫集計 API
|
||||||
|
10. フロントエンド: 在庫一覧画面
|
||||||
|
11. フロントエンド: 入出庫登録画面
|
||||||
|
12. フロントエンド: 資材マスタ管理画面
|
||||||
|
13. Navbar にメニュー追加
|
||||||
|
|
||||||
|
### やらないこと(将来フェーズ)
|
||||||
|
|
||||||
|
- 公式データ同期(FAMIC、農水省)
|
||||||
|
- 別名辞書(MaterialAlias)
|
||||||
|
- LLM 調査支援
|
||||||
|
- 施肥計画画面への在庫参照表示
|
||||||
|
- 在庫自動減算(施肥計画確定時)
|
||||||
|
- `MaterialStockSnapshot`(パフォーマンス最適化)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. バックエンド実装
|
||||||
|
|
||||||
|
### 2.1 アプリ作成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python manage.py startapp materials
|
||||||
|
mv materials apps/materials
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/materials/apps.py`:
|
||||||
|
```python
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class MaterialsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.materials'
|
||||||
|
verbose_name = '資材管理'
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/keinasystem/settings.py` に追加:
|
||||||
|
```python
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
# ... 既存 ...
|
||||||
|
'apps.materials', # ← 追加
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/keinasystem/urls.py` に追加:
|
||||||
|
```python
|
||||||
|
urlpatterns = [
|
||||||
|
# ... 既存 ...
|
||||||
|
path('api/materials/', include('apps.materials.urls')), # ← 追加
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 モデル定義 (`apps/materials/models.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Material(models.Model):
|
||||||
|
"""共通資材マスタ"""
|
||||||
|
|
||||||
|
class MaterialType(models.TextChoices):
|
||||||
|
FERTILIZER = 'fertilizer', '肥料'
|
||||||
|
PESTICIDE = 'pesticide', '農薬'
|
||||||
|
SEEDLING = 'seedling', '種苗'
|
||||||
|
OTHER = 'other', 'その他'
|
||||||
|
|
||||||
|
class StockUnit(models.TextChoices):
|
||||||
|
BAG = 'bag', '袋'
|
||||||
|
BOTTLE = 'bottle', '本'
|
||||||
|
KG = 'kg', 'kg'
|
||||||
|
LITER = 'liter', 'L'
|
||||||
|
PIECE = 'piece', '個'
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, verbose_name='資材名')
|
||||||
|
material_type = models.CharField(
|
||||||
|
max_length=20, choices=MaterialType.choices, verbose_name='資材種別'
|
||||||
|
)
|
||||||
|
maker = models.CharField(
|
||||||
|
max_length=100, blank=True, default='', verbose_name='メーカー'
|
||||||
|
)
|
||||||
|
stock_unit = models.CharField(
|
||||||
|
max_length=20, choices=StockUnit.choices, default='bag', verbose_name='在庫単位'
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='使用中')
|
||||||
|
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['material_type', 'name']
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['material_type', 'name'],
|
||||||
|
name='uniq_material_type_name',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
verbose_name = '資材'
|
||||||
|
verbose_name_plural = '資材'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.get_material_type_display()}: {self.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerProfile(models.Model):
|
||||||
|
"""肥料専用属性"""
|
||||||
|
material = models.OneToOneField(
|
||||||
|
Material, on_delete=models.CASCADE, related_name='fertilizer_profile'
|
||||||
|
)
|
||||||
|
capacity_kg = models.DecimalField(
|
||||||
|
max_digits=8, decimal_places=3, blank=True, null=True, verbose_name='1袋重量(kg)'
|
||||||
|
)
|
||||||
|
nitrogen_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='窒素(%)'
|
||||||
|
)
|
||||||
|
phosphorus_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='リン酸(%)'
|
||||||
|
)
|
||||||
|
potassium_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ(%)'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '肥料詳細'
|
||||||
|
verbose_name_plural = '肥料詳細'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'肥料詳細: {self.material.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class PesticideProfile(models.Model):
|
||||||
|
"""農薬専用属性"""
|
||||||
|
material = models.OneToOneField(
|
||||||
|
Material, on_delete=models.CASCADE, related_name='pesticide_profile'
|
||||||
|
)
|
||||||
|
registration_no = models.CharField(
|
||||||
|
max_length=100, blank=True, default='', verbose_name='農薬登録番号'
|
||||||
|
)
|
||||||
|
formulation = models.CharField(
|
||||||
|
max_length=100, blank=True, default='', verbose_name='剤型'
|
||||||
|
)
|
||||||
|
usage_unit = models.CharField(
|
||||||
|
max_length=50, blank=True, default='', verbose_name='使用単位'
|
||||||
|
)
|
||||||
|
dilution_ratio = models.CharField(
|
||||||
|
max_length=100, blank=True, default='', verbose_name='希釈倍率'
|
||||||
|
)
|
||||||
|
active_ingredient = models.CharField(
|
||||||
|
max_length=200, blank=True, default='', verbose_name='有効成分'
|
||||||
|
)
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=100, blank=True, default='', verbose_name='分類'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '農薬詳細'
|
||||||
|
verbose_name_plural = '農薬詳細'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'農薬詳細: {self.material.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class StockTransaction(models.Model):
|
||||||
|
"""入出庫履歴"""
|
||||||
|
|
||||||
|
class TransactionType(models.TextChoices):
|
||||||
|
PURCHASE = 'purchase', '入庫'
|
||||||
|
USE = 'use', '使用'
|
||||||
|
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
||||||
|
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
||||||
|
DISCARD = 'discard', '廃棄'
|
||||||
|
|
||||||
|
# 増加として扱う transaction_type のセット
|
||||||
|
INCREASE_TYPES = {
|
||||||
|
TransactionType.PURCHASE,
|
||||||
|
TransactionType.ADJUSTMENT_PLUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
material = models.ForeignKey(
|
||||||
|
Material, on_delete=models.PROTECT, related_name='stock_transactions',
|
||||||
|
verbose_name='資材'
|
||||||
|
)
|
||||||
|
transaction_type = models.CharField(
|
||||||
|
max_length=30, choices=TransactionType.choices, verbose_name='取引種別'
|
||||||
|
)
|
||||||
|
quantity = models.DecimalField(
|
||||||
|
max_digits=10, decimal_places=3, verbose_name='数量'
|
||||||
|
)
|
||||||
|
occurred_on = models.DateField(verbose_name='発生日')
|
||||||
|
note = models.TextField(blank=True, default='', verbose_name='備考')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-occurred_on', '-created_at']
|
||||||
|
verbose_name = '入出庫履歴'
|
||||||
|
verbose_name_plural = '入出庫履歴'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.material.name} {self.get_transaction_type_display()} {self.quantity}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: `StockTransaction` から以下のフィールドを意図的に除外した:
|
||||||
|
- `unit` — `Material.stock_unit` から取得すれば十分
|
||||||
|
- `reference_type` / `reference_id` — Generic FK は初期段階で不要
|
||||||
|
- `created_by` — シングルユーザーシステムのため不要
|
||||||
|
- `inventory_count` — `adjustment_plus` / `adjustment_minus` で十分表現できる
|
||||||
|
|
||||||
|
### 2.3 既存 Fertilizer との連携マイグレーション
|
||||||
|
|
||||||
|
**重要**: これは2段階で行う。
|
||||||
|
|
||||||
|
#### ステップ1: Material 関連テーブル作成
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations materials
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ステップ2: Fertilizer に material FK 追加 + データ移行
|
||||||
|
|
||||||
|
`apps/fertilizer/` に新しいマイグレーションを手動作成する。
|
||||||
|
|
||||||
|
ファイル名: `apps/fertilizer/migrations/0005_fertilizer_material.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def create_materials_for_existing_fertilizers(apps, schema_editor):
|
||||||
|
"""既存の Fertilizer ごとに Material + FertilizerProfile を作成し紐づける"""
|
||||||
|
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
|
||||||
|
Material = apps.get_model('materials', 'Material')
|
||||||
|
FertilizerProfile = apps.get_model('materials', 'FertilizerProfile')
|
||||||
|
|
||||||
|
for fert in Fertilizer.objects.all():
|
||||||
|
mat = Material.objects.create(
|
||||||
|
name=fert.name,
|
||||||
|
material_type='fertilizer',
|
||||||
|
maker=fert.maker or '',
|
||||||
|
stock_unit='bag',
|
||||||
|
is_active=True,
|
||||||
|
notes=fert.notes or '',
|
||||||
|
)
|
||||||
|
FertilizerProfile.objects.create(
|
||||||
|
material=mat,
|
||||||
|
capacity_kg=fert.capacity_kg,
|
||||||
|
nitrogen_pct=fert.nitrogen_pct,
|
||||||
|
phosphorus_pct=fert.phosphorus_pct,
|
||||||
|
potassium_pct=fert.potassium_pct,
|
||||||
|
)
|
||||||
|
fert.material = mat
|
||||||
|
fert.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migration(apps, schema_editor):
|
||||||
|
"""逆マイグレーション: material FK を null に戻す"""
|
||||||
|
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
|
||||||
|
Fertilizer.objects.all().update(material=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fertilizer', '0004_fertilizationplan_calc_settings'),
|
||||||
|
('materials', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Step 1: nullable FK として追加
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='fertilizer',
|
||||||
|
name='material',
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='legacy_fertilizer',
|
||||||
|
to='materials.Material',
|
||||||
|
verbose_name='資材マスタ',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Step 2: 既存データを移行
|
||||||
|
migrations.RunPython(
|
||||||
|
create_materials_for_existing_fertilizers,
|
||||||
|
reverse_migration,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/fertilizer/models.py` の `Fertilizer` クラスに追加:
|
||||||
|
```python
|
||||||
|
# 既存フィールドの後に追加
|
||||||
|
material = models.OneToOneField(
|
||||||
|
'materials.Material',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='legacy_fertilizer',
|
||||||
|
verbose_name='資材マスタ',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: `material` は `null=True` のままにする。施肥計画の既存ロジックには影響しない。
|
||||||
|
|
||||||
|
### 2.4 Serializer (`apps/materials/serializers.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db.models import Sum, Q
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Material, FertilizerProfile, PesticideProfile, StockTransaction
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerProfileSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = FertilizerProfile
|
||||||
|
fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct']
|
||||||
|
|
||||||
|
|
||||||
|
class PesticideProfileSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = PesticideProfile
|
||||||
|
fields = [
|
||||||
|
'registration_no', 'formulation', 'usage_unit',
|
||||||
|
'dilution_ratio', 'active_ingredient', 'category',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialReadSerializer(serializers.ModelSerializer):
|
||||||
|
"""Material 読み取り用(プロファイル・在庫含む)"""
|
||||||
|
material_type_display = serializers.CharField(
|
||||||
|
source='get_material_type_display', read_only=True
|
||||||
|
)
|
||||||
|
stock_unit_display = serializers.CharField(
|
||||||
|
source='get_stock_unit_display', read_only=True
|
||||||
|
)
|
||||||
|
fertilizer_profile = FertilizerProfileSerializer(read_only=True)
|
||||||
|
pesticide_profile = PesticideProfileSerializer(read_only=True)
|
||||||
|
current_stock = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Material
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'material_type', 'material_type_display',
|
||||||
|
'maker', 'stock_unit', 'stock_unit_display',
|
||||||
|
'is_active', 'notes',
|
||||||
|
'fertilizer_profile', 'pesticide_profile',
|
||||||
|
'current_stock',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_current_stock(self, obj):
|
||||||
|
"""StockTransaction の集計で現在庫を算出"""
|
||||||
|
txns = obj.stock_transactions.all()
|
||||||
|
increase = txns.filter(
|
||||||
|
transaction_type__in=['purchase', 'adjustment_plus']
|
||||||
|
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
|
||||||
|
decrease = txns.filter(
|
||||||
|
transaction_type__in=['use', 'adjustment_minus', 'discard']
|
||||||
|
).aggregate(total=Sum('quantity'))['total'] or Decimal('0')
|
||||||
|
return str(increase - decrease)
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialWriteSerializer(serializers.ModelSerializer):
|
||||||
|
"""Material 書き込み用"""
|
||||||
|
fertilizer_profile = FertilizerProfileSerializer(required=False)
|
||||||
|
pesticide_profile = PesticideProfileSerializer(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Material
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'material_type', 'maker', 'stock_unit',
|
||||||
|
'is_active', 'notes',
|
||||||
|
'fertilizer_profile', 'pesticide_profile',
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
fert_data = validated_data.pop('fertilizer_profile', None)
|
||||||
|
pest_data = validated_data.pop('pesticide_profile', None)
|
||||||
|
material = Material.objects.create(**validated_data)
|
||||||
|
|
||||||
|
if validated_data['material_type'] == 'fertilizer' and fert_data:
|
||||||
|
FertilizerProfile.objects.create(material=material, **fert_data)
|
||||||
|
elif validated_data['material_type'] == 'pesticide' and pest_data:
|
||||||
|
PesticideProfile.objects.create(material=material, **pest_data)
|
||||||
|
|
||||||
|
return material
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
fert_data = validated_data.pop('fertilizer_profile', None)
|
||||||
|
pest_data = validated_data.pop('pesticide_profile', None)
|
||||||
|
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
if instance.material_type == 'fertilizer' and fert_data is not None:
|
||||||
|
profile, _ = FertilizerProfile.objects.get_or_create(material=instance)
|
||||||
|
for attr, value in fert_data.items():
|
||||||
|
setattr(profile, attr, value)
|
||||||
|
profile.save()
|
||||||
|
elif instance.material_type == 'pesticide' and pest_data is not None:
|
||||||
|
profile, _ = PesticideProfile.objects.get_or_create(material=instance)
|
||||||
|
for attr, value in pest_data.items():
|
||||||
|
setattr(profile, attr, value)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class StockTransactionSerializer(serializers.ModelSerializer):
|
||||||
|
"""入出庫履歴"""
|
||||||
|
material_name = serializers.CharField(source='material.name', read_only=True)
|
||||||
|
material_type = serializers.CharField(source='material.material_type', read_only=True)
|
||||||
|
stock_unit = serializers.CharField(source='material.stock_unit', read_only=True)
|
||||||
|
stock_unit_display = serializers.CharField(
|
||||||
|
source='material.get_stock_unit_display', read_only=True
|
||||||
|
)
|
||||||
|
transaction_type_display = serializers.CharField(
|
||||||
|
source='get_transaction_type_display', read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StockTransaction
|
||||||
|
fields = [
|
||||||
|
'id', 'material', 'material_name', 'material_type',
|
||||||
|
'transaction_type', 'transaction_type_display',
|
||||||
|
'quantity', 'stock_unit', 'stock_unit_display',
|
||||||
|
'occurred_on', 'note', 'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class StockSummarySerializer(serializers.Serializer):
|
||||||
|
"""在庫集計(読み取り専用)"""
|
||||||
|
material_id = serializers.IntegerField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
material_type = serializers.CharField()
|
||||||
|
material_type_display = serializers.CharField()
|
||||||
|
stock_unit = serializers.CharField()
|
||||||
|
stock_unit_display = serializers.CharField()
|
||||||
|
is_active = serializers.BooleanField()
|
||||||
|
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
|
||||||
|
last_transaction_date = serializers.DateField(allow_null=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 View (`apps/materials/views.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db.models import Sum, Max, Q, Value, CharField
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from rest_framework import viewsets, generics, status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .models import Material, StockTransaction
|
||||||
|
from .serializers import (
|
||||||
|
MaterialReadSerializer,
|
||||||
|
MaterialWriteSerializer,
|
||||||
|
StockTransactionSerializer,
|
||||||
|
StockSummarySerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialViewSet(viewsets.ModelViewSet):
|
||||||
|
"""資材マスタ CRUD"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = Material.objects.select_related(
|
||||||
|
'fertilizer_profile', 'pesticide_profile'
|
||||||
|
).prefetch_related('stock_transactions')
|
||||||
|
|
||||||
|
material_type = self.request.query_params.get('material_type')
|
||||||
|
if material_type:
|
||||||
|
qs = qs.filter(material_type=material_type)
|
||||||
|
|
||||||
|
active = self.request.query_params.get('active')
|
||||||
|
if active is not None:
|
||||||
|
qs = qs.filter(is_active=active.lower() == 'true')
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ('create', 'update', 'partial_update'):
|
||||||
|
return MaterialWriteSerializer
|
||||||
|
return MaterialReadSerializer
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
if instance.stock_transactions.exists():
|
||||||
|
return Response(
|
||||||
|
{'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class StockTransactionViewSet(viewsets.ModelViewSet):
|
||||||
|
"""入出庫履歴 CRUD"""
|
||||||
|
serializer_class = StockTransactionSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = StockTransaction.objects.select_related('material')
|
||||||
|
|
||||||
|
material_id = self.request.query_params.get('material_id')
|
||||||
|
if material_id:
|
||||||
|
qs = qs.filter(material_id=material_id)
|
||||||
|
|
||||||
|
material_type = self.request.query_params.get('material_type')
|
||||||
|
if material_type:
|
||||||
|
qs = qs.filter(material__material_type=material_type)
|
||||||
|
|
||||||
|
date_from = self.request.query_params.get('date_from')
|
||||||
|
if date_from:
|
||||||
|
qs = qs.filter(occurred_on__gte=date_from)
|
||||||
|
|
||||||
|
date_to = self.request.query_params.get('date_to')
|
||||||
|
if date_to:
|
||||||
|
qs = qs.filter(occurred_on__lte=date_to)
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class StockSummaryView(generics.ListAPIView):
|
||||||
|
"""在庫集計一覧"""
|
||||||
|
serializer_class = StockSummarySerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
qs = Material.objects.prefetch_related('stock_transactions')
|
||||||
|
|
||||||
|
material_type = request.query_params.get('material_type')
|
||||||
|
if material_type:
|
||||||
|
qs = qs.filter(material_type=material_type)
|
||||||
|
|
||||||
|
active = request.query_params.get('active')
|
||||||
|
if active is not None:
|
||||||
|
qs = qs.filter(is_active=active.lower() == 'true')
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for mat in qs:
|
||||||
|
txns = mat.stock_transactions.all()
|
||||||
|
increase = sum(
|
||||||
|
t.quantity for t in txns
|
||||||
|
if t.transaction_type in ('purchase', 'adjustment_plus')
|
||||||
|
)
|
||||||
|
decrease = sum(
|
||||||
|
t.quantity for t in txns
|
||||||
|
if t.transaction_type in ('use', 'adjustment_minus', 'discard')
|
||||||
|
)
|
||||||
|
last_date = max(
|
||||||
|
(t.occurred_on for t in txns), default=None
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
'material_id': mat.id,
|
||||||
|
'name': mat.name,
|
||||||
|
'material_type': mat.material_type,
|
||||||
|
'material_type_display': mat.get_material_type_display(),
|
||||||
|
'stock_unit': mat.stock_unit,
|
||||||
|
'stock_unit_display': mat.get_stock_unit_display(),
|
||||||
|
'is_active': mat.is_active,
|
||||||
|
'current_stock': increase - decrease,
|
||||||
|
'last_transaction_date': last_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
serializer = StockSummarySerializer(results, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 URL (`apps/materials/urls.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'materials', views.MaterialViewSet, basename='material')
|
||||||
|
router.register(r'stock-transactions', views.StockTransactionViewSet, basename='stock-transaction')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Admin (`apps/materials/admin.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Material, FertilizerProfile, PesticideProfile, StockTransaction
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerProfileInline(admin.StackedInline):
|
||||||
|
model = FertilizerProfile
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PesticideProfileInline(admin.StackedInline):
|
||||||
|
model = PesticideProfile
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Material)
|
||||||
|
class MaterialAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active']
|
||||||
|
list_filter = ['material_type', 'is_active']
|
||||||
|
search_fields = ['name', 'maker']
|
||||||
|
inlines = [FertilizerProfileInline, PesticideProfileInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StockTransaction)
|
||||||
|
class StockTransactionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['material', 'transaction_type', 'quantity', 'occurred_on']
|
||||||
|
list_filter = ['transaction_type', 'occurred_on']
|
||||||
|
search_fields = ['material__name']
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. フロントエンド実装
|
||||||
|
|
||||||
|
### 3.1 型定義 (`frontend/src/types/index.ts` に追記)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ===== 資材管理 =====
|
||||||
|
|
||||||
|
export interface FertilizerProfile {
|
||||||
|
capacity_kg: string | null;
|
||||||
|
nitrogen_pct: string | null;
|
||||||
|
phosphorus_pct: string | null;
|
||||||
|
potassium_pct: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PesticideProfile {
|
||||||
|
registration_no: string;
|
||||||
|
formulation: string;
|
||||||
|
usage_unit: string;
|
||||||
|
dilution_ratio: string;
|
||||||
|
active_ingredient: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Material {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
||||||
|
material_type_display: string;
|
||||||
|
maker: string;
|
||||||
|
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
|
||||||
|
stock_unit_display: string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string;
|
||||||
|
fertilizer_profile: FertilizerProfile | null;
|
||||||
|
pesticide_profile: PesticideProfile | null;
|
||||||
|
current_stock: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockTransaction {
|
||||||
|
id: number;
|
||||||
|
material: number;
|
||||||
|
material_name: string;
|
||||||
|
material_type: string;
|
||||||
|
transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||||||
|
transaction_type_display: string;
|
||||||
|
quantity: string;
|
||||||
|
stock_unit: string;
|
||||||
|
stock_unit_display: string;
|
||||||
|
occurred_on: string;
|
||||||
|
note: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockSummary {
|
||||||
|
material_id: number;
|
||||||
|
name: string;
|
||||||
|
material_type: string;
|
||||||
|
material_type_display: string;
|
||||||
|
stock_unit: string;
|
||||||
|
stock_unit_display: string;
|
||||||
|
is_active: boolean;
|
||||||
|
current_stock: string;
|
||||||
|
last_transaction_date: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 画面構成
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/materials/
|
||||||
|
├── page.tsx # 在庫一覧(メイン画面)
|
||||||
|
├── masters/
|
||||||
|
│ └── page.tsx # 資材マスタ管理
|
||||||
|
└── _components/
|
||||||
|
├── StockOverview.tsx # 在庫一覧コンポーネント
|
||||||
|
├── StockTransactionForm.tsx # 入出庫登録フォーム(モーダルまたはインライン)
|
||||||
|
└── MaterialForm.tsx # 資材登録・編集フォーム
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 在庫一覧画面 (`/materials` → `page.tsx`)
|
||||||
|
|
||||||
|
**機能:**
|
||||||
|
- 全資材の現在庫を一覧表示
|
||||||
|
- `material_type` でフィルタ(タブ: 全て / 肥料 / 農薬 / その他)
|
||||||
|
- 「入出庫登録」ボタン → モーダルで入出庫フォームを開く
|
||||||
|
- 「資材マスタ」ボタン → `/materials/masters` へ遷移
|
||||||
|
|
||||||
|
**テーブル列:**
|
||||||
|
| 資材名 | 種別 | メーカー | 現在庫 | 単位 | 最終入出庫日 | 操作 |
|
||||||
|
|
||||||
|
**操作列:**
|
||||||
|
- 「入庫」ボタン(クリック → 入出庫フォームをプリセット `transaction_type=purchase`)
|
||||||
|
- 「出庫」ボタン(クリック → 入出庫フォームをプリセット `transaction_type=use`)
|
||||||
|
- 「履歴」ボタン(クリック → 資材の入出庫履歴をインライン展開 or モーダル表示)
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- `GET /api/materials/stock-summary/` で一覧取得
|
||||||
|
- `GET /api/materials/stock-summary/?material_type=fertilizer` でフィルタ
|
||||||
|
|
||||||
|
### 3.4 入出庫登録フォーム (`StockTransactionForm.tsx`)
|
||||||
|
|
||||||
|
**モーダルで表示する。**
|
||||||
|
|
||||||
|
**フォーム項目:**
|
||||||
|
- 資材(セレクト。一覧画面から呼ぶ場合はプリセット済み)
|
||||||
|
- 取引種別(セレクト: 入庫 / 使用 / 棚卸増 / 棚卸減 / 廃棄)
|
||||||
|
- 数量(数値入力)
|
||||||
|
- 発生日(日付入力、デフォルト: 今日)
|
||||||
|
- 備考(テキスト、任意)
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- `POST /api/materials/stock-transactions/`
|
||||||
|
|
||||||
|
**保存後:**
|
||||||
|
- モーダルを閉じる
|
||||||
|
- 在庫一覧を再取得
|
||||||
|
|
||||||
|
### 3.5 資材マスタ管理画面 (`/materials/masters` → `masters/page.tsx`)
|
||||||
|
|
||||||
|
**既存の `/fertilizer/masters` と同じ UX パターンを踏襲する。**
|
||||||
|
|
||||||
|
**機能:**
|
||||||
|
- 資材一覧(インライン編集テーブル)
|
||||||
|
- 新規追加(行追加)
|
||||||
|
- 編集(行内で直接編集)
|
||||||
|
- 削除(入出庫履歴がない場合のみ)
|
||||||
|
- `material_type` タブ(肥料 / 農薬 / その他)
|
||||||
|
|
||||||
|
**肥料タブの列:**
|
||||||
|
| 資材名 | メーカー | 1袋(kg) | 窒素(%) | リン酸(%) | カリ(%) | 単位 | 備考 | 操作 |
|
||||||
|
|
||||||
|
**農薬タブの列:**
|
||||||
|
| 資材名 | メーカー | 登録番号 | 剤型 | 有効成分 | 分類 | 単位 | 備考 | 操作 |
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- `GET /api/materials/materials/?material_type=fertilizer`
|
||||||
|
- `POST /api/materials/materials/` (body に `fertilizer_profile` または `pesticide_profile` を含む)
|
||||||
|
- `PUT /api/materials/materials/{id}/`
|
||||||
|
- `DELETE /api/materials/materials/{id}/`
|
||||||
|
|
||||||
|
### 3.6 Navbar への追加
|
||||||
|
|
||||||
|
`frontend/src/components/Navbar.tsx` に以下を追加:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// icon import に追加
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
|
||||||
|
// メニューボタン(「分配計画」の後に追加)
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/materials')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/materials')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4 mr-2" />
|
||||||
|
在庫管理
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API エンドポイント一覧
|
||||||
|
|
||||||
|
| メソッド | パス | 認証 | 説明 |
|
||||||
|
|----------|------|------|------|
|
||||||
|
| GET | `/api/materials/materials/` | JWT | 資材一覧(`?material_type=`, `?active=`) |
|
||||||
|
| POST | `/api/materials/materials/` | JWT | 資材作成(profile 込み) |
|
||||||
|
| GET | `/api/materials/materials/{id}/` | JWT | 資材詳細 |
|
||||||
|
| PUT | `/api/materials/materials/{id}/` | JWT | 資材更新(profile 込み) |
|
||||||
|
| DELETE | `/api/materials/materials/{id}/` | JWT | 資材削除(履歴なしの場合のみ) |
|
||||||
|
| GET | `/api/materials/stock-transactions/` | JWT | 入出庫履歴一覧(`?material_id=`, `?material_type=`, `?date_from=`, `?date_to=`) |
|
||||||
|
| POST | `/api/materials/stock-transactions/` | JWT | 入出庫登録 |
|
||||||
|
| DELETE | `/api/materials/stock-transactions/{id}/` | JWT | 入出庫削除 |
|
||||||
|
| GET | `/api/materials/stock-summary/` | JWT | 在庫集計一覧(`?material_type=`, `?active=`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 実装順序(厳守)
|
||||||
|
|
||||||
|
### Step 1: バックエンド基盤
|
||||||
|
1. `apps/materials/` ディレクトリ作成
|
||||||
|
2. `models.py` 作成(Material, FertilizerProfile, PesticideProfile, StockTransaction)
|
||||||
|
3. `apps.py` 作成
|
||||||
|
4. `settings.py` に `apps.materials` 追加
|
||||||
|
5. `python manage.py makemigrations materials`
|
||||||
|
6. `python manage.py migrate`
|
||||||
|
|
||||||
|
### Step 2: 既存 Fertilizer 連携
|
||||||
|
7. `apps/fertilizer/models.py` に `material` フィールド追加
|
||||||
|
8. `apps/fertilizer/migrations/0005_fertilizer_material.py` 作成(データ移行込み)
|
||||||
|
9. `python manage.py migrate`
|
||||||
|
|
||||||
|
### Step 3: バックエンド API
|
||||||
|
10. `serializers.py` 作成
|
||||||
|
11. `views.py` 作成
|
||||||
|
12. `urls.py` 作成
|
||||||
|
13. `admin.py` 作成
|
||||||
|
14. `keinasystem/urls.py` にルート追加
|
||||||
|
|
||||||
|
### Step 4: フロントエンド
|
||||||
|
15. `types/index.ts` に型定義追加
|
||||||
|
16. `app/materials/page.tsx` 作成(在庫一覧)
|
||||||
|
17. `app/materials/_components/StockTransactionForm.tsx` 作成
|
||||||
|
18. `app/materials/_components/StockOverview.tsx` 作成
|
||||||
|
19. `app/materials/masters/page.tsx` 作成(資材マスタ管理)
|
||||||
|
20. `app/materials/_components/MaterialForm.tsx` 作成
|
||||||
|
21. `Navbar.tsx` にメニュー追加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. テスト確認項目
|
||||||
|
|
||||||
|
### バックエンド
|
||||||
|
- [ ] `GET /api/materials/materials/` で空リストが返る
|
||||||
|
- [ ] `POST /api/materials/materials/` で肥料を作成できる(`fertilizer_profile` 込み)
|
||||||
|
- [ ] `POST /api/materials/materials/` で農薬を作成できる(`pesticide_profile` 込み)
|
||||||
|
- [ ] `DELETE /api/materials/materials/{id}/` で入出庫履歴がある資材は削除拒否
|
||||||
|
- [ ] `POST /api/materials/stock-transactions/` で入庫登録できる
|
||||||
|
- [ ] `GET /api/materials/stock-summary/` で現在庫が正しく集計される
|
||||||
|
- [ ] 入庫3袋 + 使用1袋 = 現在庫2袋
|
||||||
|
- [ ] マイグレーション後、既存 Fertilizer が Material に紐づいている
|
||||||
|
|
||||||
|
### フロントエンド
|
||||||
|
- [ ] `/materials` で在庫一覧が表示される
|
||||||
|
- [ ] 入出庫登録モーダルが開き、保存後に一覧が更新される
|
||||||
|
- [ ] `/materials/masters` で資材マスタのインライン編集ができる
|
||||||
|
- [ ] Navbar の「在庫管理」から遷移できる
|
||||||
|
- [ ] 施肥計画画面(`/fertilizer`)が変更なく動作する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 既存コードへの変更一覧(影響範囲)
|
||||||
|
|
||||||
|
| ファイル | 変更内容 |
|
||||||
|
|----------|----------|
|
||||||
|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.materials'` 追加 |
|
||||||
|
| `backend/keinasystem/urls.py` | `path('api/materials/', ...)` 追加 |
|
||||||
|
| `backend/apps/fertilizer/models.py` | `Fertilizer` に `material` OneToOneField 追加 |
|
||||||
|
| `backend/apps/fertilizer/migrations/` | `0005_fertilizer_material.py` 追加 |
|
||||||
|
| `frontend/src/types/index.ts` | Material 関連の型定義追加 |
|
||||||
|
| `frontend/src/components/Navbar.tsx` | 「在庫管理」メニュー追加 |
|
||||||
|
|
||||||
|
上記以外の既存ファイルには**一切触らない**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 参照すべき既存コード(実装パターンの手本)
|
||||||
|
|
||||||
|
| 目的 | 参照先 |
|
||||||
|
|------|--------|
|
||||||
|
| Django アプリ構成 | `backend/apps/weather/` |
|
||||||
|
| ViewSet + Router パターン | `backend/apps/fertilizer/views.py` |
|
||||||
|
| インライン編集テーブル UI | `frontend/src/app/fertilizer/masters/page.tsx` |
|
||||||
|
| 施肥計画一覧(年度セレクタ等) | `frontend/src/app/fertilizer/page.tsx` |
|
||||||
|
| Navbar ボタン追加 | `frontend/src/components/Navbar.tsx` |
|
||||||
|
| TypeScript 型定義 | `frontend/src/types/index.ts` |
|
||||||
|
| API 呼び出し | `frontend/src/lib/api.ts` |
|
||||||
|
| Next.js 14 ダイナミックルート | `frontend/src/app/fertilizer/[id]/edit/page.tsx` |
|
||||||
@@ -146,23 +146,28 @@
|
|||||||
- `material` `ForeignKey(Material)`
|
- `material` `ForeignKey(Material)`
|
||||||
- `transaction_type`
|
- `transaction_type`
|
||||||
- `quantity`
|
- `quantity`
|
||||||
- `unit`
|
|
||||||
- `occurred_on`
|
- `occurred_on`
|
||||||
- `reference_type`
|
|
||||||
- `reference_id`
|
|
||||||
- `note`
|
- `note`
|
||||||
- `created_by`
|
|
||||||
- `created_at`
|
- `created_at`
|
||||||
|
|
||||||
|
以下のフィールドは初期実装では **除外** する:
|
||||||
|
|
||||||
|
- ~~`unit`~~ → `Material.stock_unit` から取得すれば十分。トランザクションごとに単位を持つと不整合の元になる
|
||||||
|
- ~~`reference_type`~~ / ~~`reference_id`~~ → Generic FK パターンは複雑化の元。将来の施肥計画連携時に必要なら追加する
|
||||||
|
- ~~`created_by`~~ → シングルユーザーシステムのため不要。マルチユーザー対応時に追加する
|
||||||
|
|
||||||
`transaction_type` の候補:
|
`transaction_type` の候補:
|
||||||
|
|
||||||
- `purchase`
|
- `purchase`
|
||||||
- `use`
|
- `use`
|
||||||
- `adjustment_plus`
|
- `adjustment_plus`
|
||||||
- `adjustment_minus`
|
- `adjustment_minus`
|
||||||
- `inventory_count`
|
|
||||||
- `discard`
|
- `discard`
|
||||||
|
|
||||||
|
以下は初期実装では除外する:
|
||||||
|
|
||||||
|
- ~~`inventory_count`~~ → `adjustment_plus` / `adjustment_minus` で棚卸差異を十分表現できる
|
||||||
|
|
||||||
`quantity` は正の数で持ち、増減方向は `transaction_type` で判定する方式を推奨する。
|
`quantity` は正の数で持ち、増減方向は `transaction_type` で判定する方式を推奨する。
|
||||||
|
|
||||||
#### MaterialStockSnapshot
|
#### MaterialStockSnapshot
|
||||||
@@ -237,15 +242,13 @@ class StockTransaction(models.Model):
|
|||||||
USE = 'use', '使用'
|
USE = 'use', '使用'
|
||||||
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
||||||
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
||||||
INVENTORY_COUNT = 'inventory_count', '棚卸記録'
|
|
||||||
DISCARD = 'discard', '廃棄'
|
DISCARD = 'discard', '廃棄'
|
||||||
|
|
||||||
material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name='stock_transactions')
|
material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name='stock_transactions')
|
||||||
transaction_type = models.CharField(max_length=30, choices=TransactionType.choices)
|
transaction_type = models.CharField(max_length=30, choices=TransactionType.choices)
|
||||||
quantity = models.DecimalField(max_digits=10, decimal_places=3)
|
quantity = models.DecimalField(max_digits=10, decimal_places=3)
|
||||||
occurred_on = models.DateField()
|
occurred_on = models.DateField()
|
||||||
note = models.TextField(blank=True, null=True)
|
note = models.TextField(blank=True, default='')
|
||||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -425,15 +428,14 @@ backend/apps/materials/
|
|||||||
### 9.1 新規画面
|
### 9.1 新規画面
|
||||||
|
|
||||||
- `/materials`
|
- `/materials`
|
||||||
- 資材一覧
|
- 在庫一覧(メイン画面)。在庫集計表 + 入出庫登録モーダル
|
||||||
- `/materials/fertilizers`
|
- `/materials/masters`
|
||||||
- 肥料マスタ管理
|
- 資材マスタ管理(肥料/農薬/その他をタブ切り替え、インライン編集テーブル)
|
||||||
- `/materials/pesticides`
|
|
||||||
- 農薬マスタ管理
|
**設計判断**: 入出庫登録は独立ページではなくモーダル方式にする。
|
||||||
- `/materials/stock`
|
理由: 既存の肥料マスタ画面(`/fertilizer/masters`)のインライン編集パターンに合わせ、
|
||||||
- 在庫一覧
|
画面遷移を減らして操作効率を上げるため。
|
||||||
- `/materials/stock/new`
|
肥料マスタと農薬マスタを別ページに分けず、1画面でタブ切り替えにする。
|
||||||
- 入出庫登録
|
|
||||||
|
|
||||||
### 9.2 一覧画面の基本列
|
### 9.2 一覧画面の基本列
|
||||||
|
|
||||||
@@ -1550,3 +1552,32 @@ alias 例:
|
|||||||
- 補助: `alias辞書`
|
- 補助: `alias辞書`
|
||||||
|
|
||||||
この構成であれば、コストを抑えつつ、現場名・ブランド名・保証票名の差異にも対応しやすい。
|
この構成であれば、コストを抑えつつ、現場名・ブランド名・保証票名の差異にも対応しやすい。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. レビュー記録(2026-03-14)
|
||||||
|
|
||||||
|
既存コードとの整合性レビューを実施し、以下の修正を反映した。
|
||||||
|
|
||||||
|
### 22.1 StockTransaction フィールドの簡素化
|
||||||
|
|
||||||
|
以下のフィールドを初期実装から除外した。
|
||||||
|
|
||||||
|
| 除外フィールド | 理由 |
|
||||||
|
|----------------|------|
|
||||||
|
| `unit` | `Material.stock_unit` から取得すれば十分。トランザクションごとに単位を持つと不整合の元 |
|
||||||
|
| `reference_type` / `reference_id` | Generic FK パターンは複雑化の元。将来の施肥計画連携時に必要なら追加 |
|
||||||
|
| `created_by` | シングルユーザーシステムのため不要。マルチユーザー対応時に追加 |
|
||||||
|
| `inventory_count` | `adjustment_plus` / `adjustment_minus` で棚卸差異を十分表現可能 |
|
||||||
|
|
||||||
|
### 22.2 フロントエンド画面構成の変更
|
||||||
|
|
||||||
|
- 入出庫登録を独立ページ (`/materials/stock/new`) → モーダル方式に変更
|
||||||
|
- 肥料マスタ・農薬マスタを別ページ → 1画面タブ切り替え (`/materials/masters`) に統合
|
||||||
|
- 理由: 既存の `/fertilizer/masters` のインライン編集パターンに合わせ、画面遷移を減らす
|
||||||
|
|
||||||
|
### 22.3 実装フェーズの整理
|
||||||
|
|
||||||
|
- セクション1〜16: Phase 1(初期実装)
|
||||||
|
- セクション17〜21: Phase 2(公式データ同期、alias辞書、LLM調査支援)
|
||||||
|
- CODEX への初回作業指示は Phase 1 のみを対象とする
|
||||||
|
|||||||
Reference in New Issue
Block a user