Compare commits
10 Commits
7825f0eb30
...
e3c21d6e81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c21d6e81 | ||
|
|
72b4d670fe | ||
|
|
42b11a5df8 | ||
|
|
497bc87c24 | ||
|
|
67d4197b7f | ||
|
|
1b619c44a0 | ||
|
|
f1512febde | ||
|
|
776a269d6d | ||
|
|
1425094107 | ||
|
|
f74dc4c4b7 |
@@ -56,7 +56,15 @@
|
||||
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
||||
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
|
||||
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(xargs cat:*)",
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_dd08c8854e486d12__ echo \"=== Fertilization Plans \\(check is_confirmed/confirmed_at\\) ===\" curl -s http://localhost:8000/api/fertilizer/plans/?year=2026 -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(python -m json.tool)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(git diff:*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\akira\\AppData\\Local\\Temp"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ out/
|
||||
db.sqlite3
|
||||
postgres_data/
|
||||
nul
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
830
CODEX.md
Normal file
830
CODEX.md
Normal file
@@ -0,0 +1,830 @@
|
||||
# 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` |
|
||||
BIN
all_fertilizer.zip
Normal file
BIN
all_fertilizer.zip
Normal file
Binary file not shown.
52135
all_fertilizer/1.全件.csv
Normal file
52135
all_fertilizer/1.全件.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_materials_for_existing_fertilizers(apps, schema_editor):
|
||||
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
|
||||
Material = apps.get_model('materials', 'Material')
|
||||
FertilizerProfile = apps.get_model('materials', 'FertilizerProfile')
|
||||
|
||||
for fertilizer in Fertilizer.objects.all():
|
||||
material = Material.objects.create(
|
||||
name=fertilizer.name,
|
||||
material_type='fertilizer',
|
||||
maker=fertilizer.maker or '',
|
||||
stock_unit='bag',
|
||||
is_active=True,
|
||||
notes=fertilizer.notes or '',
|
||||
)
|
||||
FertilizerProfile.objects.create(
|
||||
material=material,
|
||||
capacity_kg=fertilizer.capacity_kg,
|
||||
nitrogen_pct=fertilizer.nitrogen_pct,
|
||||
phosphorus_pct=fertilizer.phosphorus_pct,
|
||||
potassium_pct=fertilizer.potassium_pct,
|
||||
)
|
||||
fertilizer.material = material
|
||||
fertilizer.save(update_fields=['material'])
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
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 = [
|
||||
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='資材マスタ',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(create_materials_for_existing_fertilizers, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
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='散布確定日時'),
|
||||
),
|
||||
]
|
||||
@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
|
||||
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
||||
)
|
||||
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
||||
material = models.OneToOneField(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='legacy_fertilizer',
|
||||
verbose_name='資材マスタ',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '肥料マスタ'
|
||||
@@ -35,6 +43,8 @@ class FertilizationPlan(models.Model):
|
||||
related_name='fertilization_plans', verbose_name='品種'
|
||||
)
|
||||
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
|
||||
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -3,9 +3,25 @@ from .models import Fertilizer, FertilizationPlan, FertilizationEntry, Distribut
|
||||
|
||||
|
||||
class FertilizerSerializer(serializers.ModelSerializer):
|
||||
material_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Fertilizer
|
||||
fields = '__all__'
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'maker',
|
||||
'capacity_kg',
|
||||
'nitrogen_pct',
|
||||
'phosphorus_pct',
|
||||
'potassium_pct',
|
||||
'notes',
|
||||
'material',
|
||||
'material_id',
|
||||
]
|
||||
|
||||
def get_material_id(self, obj):
|
||||
return obj.material_id
|
||||
|
||||
|
||||
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||
@@ -26,12 +42,15 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
fertilizer_count = serializers.SerializerMethodField()
|
||||
is_confirmed = serializers.BooleanField(read_only=True)
|
||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = [
|
||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
|
||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
|
||||
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
|
||||
@@ -4,12 +4,19 @@ from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from weasyprint import HTML
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.stock_service import (
|
||||
confirm_spreading as confirm_spreading_service,
|
||||
create_reserves_for_plan,
|
||||
delete_reserves_for_plan,
|
||||
unconfirm_spreading,
|
||||
)
|
||||
from apps.plans.models import Plan, Variety
|
||||
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
||||
from .serializers import (
|
||||
@@ -33,7 +40,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
||||
'entries', 'entries__field', 'entries__fertilizer'
|
||||
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
|
||||
)
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
@@ -45,6 +52,20 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
return FertilizationPlanWriteSerializer
|
||||
return FertilizationPlanSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.is_confirmed:
|
||||
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
delete_reserves_for_plan(instance)
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def pdf(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
@@ -99,6 +120,67 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||
return response
|
||||
|
||||
@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:
|
||||
field_id = entry.get('field_id')
|
||||
fertilizer_id = entry.get('fertilizer_id')
|
||||
if not field_id or not fertilizer_id:
|
||||
return Response(
|
||||
{'detail': 'field_id と fertilizer_id が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
|
||||
except InvalidOperation:
|
||||
return Response(
|
||||
{'detail': 'actual_bags は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
actual_entries.append(
|
||||
{
|
||||
'field_id': field_id,
|
||||
'fertilizer_id': fertilizer_id,
|
||||
'actual_bags': actual_bags,
|
||||
}
|
||||
)
|
||||
|
||||
confirm_spreading_service(plan, actual_entries)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='unconfirm')
|
||||
def unconfirm(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
|
||||
if not plan.is_confirmed:
|
||||
return Response(
|
||||
{'detail': 'この計画はまだ確定されていません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
unconfirm_spreading(plan)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class CandidateFieldsView(APIView):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
|
||||
1
backend/apps/materials/__init__.py
Normal file
1
backend/apps/materials/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
backend/apps/materials/admin.py
Normal file
28
backend/apps/materials/admin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import FertilizerProfile, Material, 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']
|
||||
8
backend/apps/materials/apps.py
Normal file
8
backend/apps/materials/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MaterialsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.materials'
|
||||
verbose_name = '資材管理'
|
||||
|
||||
87
backend/apps/materials/migrations/0001_initial.py
Normal file
87
backend/apps/materials/migrations/0001_initial.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Material',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='資材名')),
|
||||
('material_type', models.CharField(choices=[('fertilizer', '肥料'), ('pesticide', '農薬'), ('seedling', '種苗'), ('other', 'その他')], max_length=20, verbose_name='資材種別')),
|
||||
('maker', models.CharField(blank=True, default='', max_length=100, verbose_name='メーカー')),
|
||||
('stock_unit', models.CharField(choices=[('bag', '袋'), ('bottle', '本'), ('kg', 'kg'), ('liter', 'L'), ('piece', '個')], default='bag', max_length=20, 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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '資材',
|
||||
'verbose_name_plural': '資材',
|
||||
'ordering': ['material_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FertilizerProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')),
|
||||
('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素(%)')),
|
||||
('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸(%)')),
|
||||
('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ(%)')),
|
||||
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fertilizer_profile', to='materials.material')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '肥料詳細',
|
||||
'verbose_name_plural': '肥料詳細',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PesticideProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('registration_no', models.CharField(blank=True, default='', max_length=100, verbose_name='農薬登録番号')),
|
||||
('formulation', models.CharField(blank=True, default='', max_length=100, verbose_name='剤型')),
|
||||
('usage_unit', models.CharField(blank=True, default='', max_length=50, verbose_name='使用単位')),
|
||||
('dilution_ratio', models.CharField(blank=True, default='', max_length=100, verbose_name='希釈倍率')),
|
||||
('active_ingredient', models.CharField(blank=True, default='', max_length=200, verbose_name='有効成分')),
|
||||
('category', models.CharField(blank=True, default='', max_length=100, verbose_name='分類')),
|
||||
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pesticide_profile', to='materials.material')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '農薬詳細',
|
||||
'verbose_name_plural': '農薬詳細',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transaction_type', models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別')),
|
||||
('quantity', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(decimal.Decimal('0.001'))], verbose_name='数量')),
|
||||
('occurred_on', models.DateField(verbose_name='発生日')),
|
||||
('note', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_transactions', to='materials.material', verbose_name='資材')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '入出庫履歴',
|
||||
'verbose_name_plural': '入出庫履歴',
|
||||
'ordering': ['-occurred_on', '-created_at', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='material',
|
||||
constraint=models.UniqueConstraint(fields=('material_type', 'name'), name='uniq_material_type_name'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
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='施肥計画',
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/materials/migrations/__init__.py
Normal file
1
backend/apps/materials/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
220
backend/apps/materials/models.py
Normal file
220
backend/apps/materials/models.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
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=StockUnit.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', '使用'
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
validators=[MinValueValidator(Decimal('0.001'))],
|
||||
verbose_name='数量',
|
||||
)
|
||||
occurred_on = models.DateField(verbose_name='発生日')
|
||||
note = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
fertilization_plan = models.ForeignKey(
|
||||
'fertilizer.FertilizationPlan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='stock_reservations',
|
||||
verbose_name='施肥計画',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-occurred_on', '-created_at', '-id']
|
||||
verbose_name = '入出庫履歴'
|
||||
verbose_name_plural = '入出庫履歴'
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{self.material.name} '
|
||||
f'{self.get_transaction_type_display()} '
|
||||
f'{self.quantity}'
|
||||
)
|
||||
215
backend/apps/materials/serializers.py
Normal file
215
backend/apps/materials/serializers.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
FertilizerProfile,
|
||||
Material,
|
||||
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_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):
|
||||
transactions = list(obj.stock_transactions.all())
|
||||
increase = sum(
|
||||
transaction.quantity
|
||||
for transaction in transactions
|
||||
if transaction.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
)
|
||||
decrease = sum(
|
||||
transaction.quantity
|
||||
for transaction in transactions
|
||||
if transaction.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class MaterialWriteSerializer(serializers.ModelSerializer):
|
||||
fertilizer_profile = FertilizerProfileSerializer(required=False, allow_null=True)
|
||||
pesticide_profile = PesticideProfileSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Material
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'material_type',
|
||||
'maker',
|
||||
'stock_unit',
|
||||
'is_active',
|
||||
'notes',
|
||||
'fertilizer_profile',
|
||||
'pesticide_profile',
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
material_type = attrs.get('material_type')
|
||||
if self.instance is not None and material_type is None:
|
||||
material_type = self.instance.material_type
|
||||
|
||||
fertilizer_profile = attrs.get('fertilizer_profile')
|
||||
pesticide_profile = attrs.get('pesticide_profile')
|
||||
|
||||
if material_type == Material.MaterialType.FERTILIZER and pesticide_profile:
|
||||
raise serializers.ValidationError(
|
||||
{'pesticide_profile': '肥料には農薬詳細を設定できません。'}
|
||||
)
|
||||
if material_type == Material.MaterialType.PESTICIDE and fertilizer_profile:
|
||||
raise serializers.ValidationError(
|
||||
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
||||
)
|
||||
if (
|
||||
material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER}
|
||||
and (fertilizer_profile or pesticide_profile)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'種苗・その他には詳細プロファイルを設定できません。'
|
||||
)
|
||||
return attrs
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
|
||||
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
|
||||
|
||||
material = Material.objects.create(**validated_data)
|
||||
self._save_profiles(material, fertilizer_profile_data, pesticide_profile_data)
|
||||
return material
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
|
||||
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
self._save_profiles(instance, fertilizer_profile_data, pesticide_profile_data)
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
return MaterialReadSerializer(instance, context=self.context).data
|
||||
|
||||
def _save_profiles(self, material, fertilizer_profile_data, pesticide_profile_data):
|
||||
if material.material_type == Material.MaterialType.FERTILIZER:
|
||||
if fertilizer_profile_data is not None:
|
||||
profile, _ = FertilizerProfile.objects.get_or_create(material=material)
|
||||
for attr, value in fertilizer_profile_data.items():
|
||||
setattr(profile, attr, value)
|
||||
profile.save()
|
||||
PesticideProfile.objects.filter(material=material).delete()
|
||||
return
|
||||
|
||||
if material.material_type == Material.MaterialType.PESTICIDE:
|
||||
if pesticide_profile_data is not None:
|
||||
profile, _ = PesticideProfile.objects.get_or_create(material=material)
|
||||
for attr, value in pesticide_profile_data.items():
|
||||
setattr(profile, attr, value)
|
||||
profile.save()
|
||||
FertilizerProfile.objects.filter(material=material).delete()
|
||||
return
|
||||
|
||||
FertilizerProfile.objects.filter(material=material).delete()
|
||||
PesticideProfile.objects.filter(material=material).delete()
|
||||
|
||||
|
||||
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',
|
||||
'fertilization_plan',
|
||||
'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()
|
||||
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)
|
||||
100
backend/apps/materials/stock_service.py
Normal file
100
backend/apps/materials/stock_service.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import StockTransaction
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_reserves_for_plan(plan):
|
||||
"""施肥計画の引当を全置換で作り直す。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
if plan.is_confirmed:
|
||||
return
|
||||
|
||||
occurred_on = (
|
||||
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
|
||||
)
|
||||
|
||||
for entry in plan.entries.select_related('fertilizer__material'):
|
||||
material = getattr(entry.fertilizer, 'material', None)
|
||||
if material is None:
|
||||
continue
|
||||
StockTransaction.objects.create(
|
||||
material=material,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
quantity=entry.bags,
|
||||
occurred_on=occurred_on,
|
||||
note=f'施肥計画「{plan.name}」からの引当',
|
||||
fertilization_plan=plan,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_reserves_for_plan(plan):
|
||||
"""施肥計画に紐づく引当のみ削除する。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_spreading(plan, actual_entries):
|
||||
"""引当を使用実績へ変換して施肥計画を確定済みにする。"""
|
||||
from apps.fertilizer.models import Fertilizer
|
||||
|
||||
delete_reserves_for_plan(plan)
|
||||
|
||||
for entry_data in actual_entries:
|
||||
actual_bags = _to_decimal(entry_data.get('actual_bags'))
|
||||
if actual_bags <= 0:
|
||||
continue
|
||||
|
||||
fertilizer = (
|
||||
Fertilizer.objects.select_related('material')
|
||||
.filter(id=entry_data['fertilizer_id'])
|
||||
.first()
|
||||
)
|
||||
if fertilizer is None or fertilizer.material is None:
|
||||
continue
|
||||
|
||||
StockTransaction.objects.create(
|
||||
material=fertilizer.material,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
quantity=actual_bags,
|
||||
occurred_on=timezone.localdate(),
|
||||
note=f'施肥計画「{plan.name}」散布確定',
|
||||
fertilization_plan=plan,
|
||||
)
|
||||
|
||||
plan.is_confirmed = True
|
||||
plan.confirmed_at = timezone.now()
|
||||
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def unconfirm_spreading(plan):
|
||||
"""散布確定を取り消し、USE トランザクションを削除して引当を再作成する。"""
|
||||
StockTransaction.objects.filter(
|
||||
fertilization_plan=plan,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
).delete()
|
||||
|
||||
plan.is_confirmed = False
|
||||
plan.confirmed_at = None
|
||||
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
|
||||
|
||||
create_reserves_for_plan(plan)
|
||||
|
||||
|
||||
def _to_decimal(value):
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Decimal('0')
|
||||
18
backend/apps/materials/urls.py
Normal file
18
backend/apps/materials/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.urls import include, path
|
||||
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'),
|
||||
]
|
||||
164
backend/apps/materials/views.py
Normal file
164
backend/apps/materials/views.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import generics, status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Material, StockTransaction
|
||||
from .serializers import (
|
||||
MaterialReadSerializer,
|
||||
MaterialWriteSerializer,
|
||||
StockSummarySerializer,
|
||||
StockTransactionSerializer,
|
||||
)
|
||||
|
||||
|
||||
class MaterialViewSet(viewsets.ModelViewSet):
|
||||
"""資材マスタ CRUD"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Material.objects.select_related(
|
||||
'fertilizer_profile',
|
||||
'pesticide_profile',
|
||||
).prefetch_related('stock_transactions')
|
||||
|
||||
material_type = self.request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material_type=material_type)
|
||||
|
||||
active = self.request.query_params.get('active')
|
||||
if active is not None:
|
||||
queryset = queryset.filter(is_active=active.lower() == 'true')
|
||||
|
||||
return queryset
|
||||
|
||||
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):
|
||||
queryset = StockTransaction.objects.select_related('material')
|
||||
|
||||
material_id = self.request.query_params.get('material_id')
|
||||
if material_id:
|
||||
queryset = queryset.filter(material_id=material_id)
|
||||
|
||||
material_type = self.request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material__material_type=material_type)
|
||||
|
||||
date_from = self.request.query_params.get('date_from')
|
||||
if date_from:
|
||||
queryset = queryset.filter(occurred_on__gte=date_from)
|
||||
|
||||
date_to = self.request.query_params.get('date_to')
|
||||
if date_to:
|
||||
queryset = queryset.filter(occurred_on__lte=date_to)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class StockSummaryView(generics.ListAPIView):
|
||||
"""在庫集計一覧"""
|
||||
|
||||
serializer_class = StockSummarySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Material.objects.none()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = Material.objects.prefetch_related('stock_transactions').order_by(
|
||||
'material_type',
|
||||
'name',
|
||||
)
|
||||
|
||||
material_type = request.query_params.get('material_type')
|
||||
if material_type:
|
||||
queryset = queryset.filter(material_type=material_type)
|
||||
|
||||
active = request.query_params.get('active')
|
||||
if active is not None:
|
||||
queryset = queryset.filter(is_active=active.lower() == 'true')
|
||||
|
||||
results = []
|
||||
for material in queryset:
|
||||
results.append(_build_stock_summary(material))
|
||||
|
||||
serializer = self.get_serializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class FertilizerStockView(generics.ListAPIView):
|
||||
"""施肥計画画面用: 肥料の在庫情報を返す"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = StockSummarySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Material.objects.none()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = Material.objects.filter(
|
||||
material_type=Material.MaterialType.FERTILIZER,
|
||||
is_active=True,
|
||||
).prefetch_related('stock_transactions').order_by('name')
|
||||
|
||||
results = [_build_stock_summary(material) for material in queryset]
|
||||
serializer = self.get_serializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def _build_stock_summary(material):
|
||||
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 == StockTransaction.TransactionType.RESERVE
|
||||
)
|
||||
available = increase - decrease if transactions else Decimal('0')
|
||||
last_date = max((txn.occurred_on for txn in transactions), default=None)
|
||||
return {
|
||||
'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': available + reserved,
|
||||
'reserved_stock': reserved,
|
||||
'available_stock': available,
|
||||
'last_transaction_date': last_date,
|
||||
}
|
||||
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
|
||||
'apps.mail',
|
||||
'apps.weather',
|
||||
'apps.fertilizer',
|
||||
'apps.materials',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -58,4 +58,5 @@ urlpatterns = [
|
||||
path('api/mail/', include('apps.mail.urls')),
|
||||
path('api/weather/', include('apps.weather.urls')),
|
||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||
path('api/materials/', include('apps.materials.urls')),
|
||||
]
|
||||
|
||||
BIN
designated_mix_national.zip
Normal file
BIN
designated_mix_national.zip
Normal file
Binary file not shown.
130670
designated_mix_national/6. 指定混合.csv
Normal file
130670
designated_mix_national/6. 指定混合.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
# マスタードキュメント:施肥計画機能
|
||||
|
||||
> **作成**: 2026-03-01
|
||||
> **最終更新**: 2026-03-01
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理)
|
||||
> **実装状況**: 実装完了・本番稼働中(最終 commit deb03ef)
|
||||
> **最終更新**: 2026-03-15
|
||||
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布確定)
|
||||
> **実装状況**: 実装完了・本番稼働中
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力する。
|
||||
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当と散布確定まで一連で扱う。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
|---|---|
|
||||
| 肥料マスタ管理 | 肥料購入管理 |
|
||||
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て) |
|
||||
| 3方式の自動計算 | 施肥作業の実績記録 |
|
||||
| 3方式の自動計算 | 個別作業日報の詳細管理 |
|
||||
| 作付け計画からの圃場自動取得 | |
|
||||
| PDF出力(圃場×肥料マトリクス表) | |
|
||||
| 在庫引当・引当解除 | |
|
||||
| 散布確定(計画値確認 + 実績入力) | |
|
||||
|
||||
---
|
||||
|
||||
@@ -47,6 +49,8 @@
|
||||
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||
| year | int | required | 年度 |
|
||||
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||
| is_confirmed | bool | default=False | 散布確定済みフラグ |
|
||||
| confirmed_at | datetime | nullable | 散布確定日時 |
|
||||
| created_at | datetime | auto | |
|
||||
| updated_at | datetime | auto | |
|
||||
|
||||
@@ -102,6 +106,8 @@
|
||||
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(引当 → 使用へ変換) |
|
||||
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消(使用 → 引当に戻す) |
|
||||
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||
|
||||
一覧レスポンス例(FertilizationPlan):
|
||||
@@ -113,6 +119,8 @@
|
||||
"variety": 3,
|
||||
"variety_name": "コシヒカリ",
|
||||
"crop_name": "米",
|
||||
"is_confirmed": false,
|
||||
"confirmed_at": null,
|
||||
"field_count": 12,
|
||||
"fertilizer_count": 2,
|
||||
"entries": [
|
||||
@@ -146,6 +154,19 @@ POST/PUT リクエスト例:
|
||||
|
||||
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
||||
|
||||
散布確定 API リクエスト例:
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
|
||||
{"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `actual_bags > 0`: 対応する引当を使用実績へ変換
|
||||
- `actual_bags = 0`: 未散布として引当解除
|
||||
|
||||
### 圃場候補取得
|
||||
|
||||
```
|
||||
@@ -269,8 +290,8 @@ GET /api/plans/crops/
|
||||
### 施肥計画一覧(`/fertilizer`)
|
||||
|
||||
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数
|
||||
- 操作ボタン: PDF出力・編集・削除
|
||||
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
|
||||
- 操作ボタン: PDF出力・編集・削除・散布確定
|
||||
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||
|
||||
### 肥料マスタ(`/fertilizer/masters`)
|
||||
@@ -295,6 +316,12 @@ GET /api/plans/crops/
|
||||
6. **手動調整**: マトリクス表のセルを直接編集
|
||||
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||
|
||||
#### 在庫連携・確定状態
|
||||
|
||||
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
|
||||
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
|
||||
- 「確定取消」で使用実績を引当に戻し、再編集できる
|
||||
|
||||
#### マトリクスの表示仕様
|
||||
|
||||
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
|
||||
@@ -302,6 +329,15 @@ GET /api/plans/crops/
|
||||
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||
|
||||
### 散布確定モーダル(`/fertilizer` 一覧から起動)
|
||||
|
||||
- 全画面遷移ではなくモーダル表示
|
||||
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行`、`肥料 = 列` のマトリクス表を採用
|
||||
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
|
||||
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
|
||||
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
|
||||
- `0` を入力したセルは未散布として扱い、対応する引当を解除する
|
||||
|
||||
#### State 構成
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import { FertilizationPlan } from '@/types';
|
||||
|
||||
interface ConfirmSpreadingModalProps {
|
||||
plan: FertilizationPlan | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
type ActualMap = Record<string, string>;
|
||||
|
||||
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
|
||||
type EntryMatrix = Record<number, Record<number, string>>;
|
||||
|
||||
export default function ConfirmSpreadingModal({
|
||||
plan,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: ConfirmSpreadingModalProps) {
|
||||
const [actuals, setActuals] = useState<ActualMap>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextActuals: ActualMap = {};
|
||||
plan.entries.forEach((entry) => {
|
||||
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
|
||||
});
|
||||
setActuals(nextActuals);
|
||||
setError(null);
|
||||
}, [isOpen, plan]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!plan) {
|
||||
return {
|
||||
fields: [] as { id: number; name: string; areaTan: string | undefined }[],
|
||||
fertilizers: [] as { id: number; name: string }[],
|
||||
planned: {} as EntryMatrix,
|
||||
};
|
||||
}
|
||||
|
||||
const fieldMap = new Map<number, { id: number; name: string; areaTan: string | undefined }>();
|
||||
const fertilizerMap = new Map<number, { id: number; name: string }>();
|
||||
const planned: EntryMatrix = {};
|
||||
|
||||
plan.entries.forEach((entry) => {
|
||||
if (!fieldMap.has(entry.field)) {
|
||||
fieldMap.set(entry.field, {
|
||||
id: entry.field,
|
||||
name: entry.field_name ?? `圃場ID:${entry.field}`,
|
||||
areaTan: entry.field_area_tan,
|
||||
});
|
||||
}
|
||||
if (!fertilizerMap.has(entry.fertilizer)) {
|
||||
fertilizerMap.set(entry.fertilizer, {
|
||||
id: entry.fertilizer,
|
||||
name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
|
||||
});
|
||||
}
|
||||
if (!planned[entry.field]) {
|
||||
planned[entry.field] = {};
|
||||
}
|
||||
planned[entry.field][entry.fertilizer] = String(entry.bags);
|
||||
});
|
||||
|
||||
return {
|
||||
fields: Array.from(fieldMap.values()),
|
||||
fertilizers: Array.from(fertilizerMap.values()),
|
||||
planned,
|
||||
};
|
||||
}, [plan]);
|
||||
|
||||
if (!isOpen || !plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
|
||||
entries: plan.entries.map((entry) => ({
|
||||
field_id: entry.field,
|
||||
fertilizer_id: entry.fertilizer,
|
||||
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
|
||||
})),
|
||||
});
|
||||
await onConfirmed();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: '散布確定に失敗しました。';
|
||||
setError(detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const numericValue = (value: string | undefined) => {
|
||||
const parsed = parseFloat(value ?? '0');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const actualTotalByField = (fieldId: number) =>
|
||||
layout.fertilizers.reduce(
|
||||
(sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]),
|
||||
0
|
||||
);
|
||||
|
||||
const actualTotalByFertilizer = (fertilizerId: number) =>
|
||||
layout.fields.reduce(
|
||||
(sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
|
||||
<div className="max-h-[92vh] w-full max-w-[95vw] overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
散布確定: 「{plan.name}」
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
施肥計画編集と同じ並びで、各セルの計画値を確認しながら実績数量を入力します。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(92vh-144px)] overflow-y-auto bg-gray-50 px-6 py-5">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
||||
<div className="grid gap-3 text-sm text-gray-700 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">年度</div>
|
||||
<div className="font-medium">{plan.year}年度</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">作物 / 品種</div>
|
||||
<div className="font-medium">
|
||||
{plan.crop_name} / {plan.variety_name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">対象圃場</div>
|
||||
<div className="font-medium">{plan.field_count}筆</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">肥料数</div>
|
||||
<div className="font-medium">{plan.fertilizer_count}種</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-800">
|
||||
各セルの灰色表示が計画値、入力欄が散布実績です。「0」を入力したセルは未散布として引当解除されます。
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap">
|
||||
圃場名
|
||||
</th>
|
||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
||||
面積(反)
|
||||
</th>
|
||||
{layout.fertilizers.map((fertilizer) => (
|
||||
<th
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 px-3 py-2 text-center font-medium text-gray-700 whitespace-nowrap"
|
||||
>
|
||||
<div>{fertilizer.name}</div>
|
||||
<div className="mt-0.5 text-[11px] font-normal text-gray-400">
|
||||
計画 / 実績
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
||||
実績合計
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.fields.map((field) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-200 px-4 py-2 whitespace-nowrap text-gray-800">
|
||||
{field.name}
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-600 whitespace-nowrap">
|
||||
{field.areaTan ?? '-'}
|
||||
</td>
|
||||
{layout.fertilizers.map((fertilizer) => {
|
||||
const key = entryKey(field.id, fertilizer.id);
|
||||
const planned = layout.planned[field.id]?.[fertilizer.id];
|
||||
const hasEntry = planned !== undefined;
|
||||
return (
|
||||
<td key={key} className="border border-gray-200 px-2 py-2">
|
||||
{hasEntry ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-[11px] text-gray-400">
|
||||
計画 {planned}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={actuals[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setActuals((prev) => ({
|
||||
...prev,
|
||||
[key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-300">-</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-700">
|
||||
{actualTotalByField(field.id).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-4 py-2">合計</td>
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
|
||||
{layout.fields
|
||||
.reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0)
|
||||
.toFixed(2)}
|
||||
</td>
|
||||
{layout.fertilizers.map((fertilizer) => (
|
||||
<td
|
||||
key={fertilizer.id}
|
||||
className="border border-gray-200 px-3 py-2 text-right text-gray-700"
|
||||
>
|
||||
{actualTotalByFertilizer(fertilizer.id).toFixed(2)}
|
||||
</td>
|
||||
))}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-green-700">
|
||||
{layout.fields
|
||||
.reduce((sum, field) => sum + actualTotalByField(field.id), 0)
|
||||
.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
一括確定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
|
||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
|
||||
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
|
||||
|
||||
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
|
||||
|
||||
@@ -63,6 +63,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||||
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||||
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
||||
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
|
||||
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -71,15 +75,26 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
// ─── 初期データ取得
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
|
||||
const [cropsRes, fertsRes, fieldsRes, stockRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fertilizer/fertilizers/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
api.get('/materials/fertilizer-stock/'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFertilizers(fertsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
setStockByMaterialId(
|
||||
stockRes.data.reduce(
|
||||
(acc: Record<number, StockSummary>, summary: StockSummary) => {
|
||||
acc[summary.material_id] = summary;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
||||
@@ -87,6 +102,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
setName(plan.name);
|
||||
setYear(plan.year);
|
||||
setVarietyId(plan.variety);
|
||||
setIsConfirmed(plan.is_confirmed);
|
||||
setConfirmedAt(plan.confirmed_at);
|
||||
|
||||
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||
@@ -110,6 +127,12 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
||||
});
|
||||
setAdjusted(newAdjusted);
|
||||
setInitialPlanTotals(
|
||||
plan.entries.reduce((acc: Record<number, number>, entry) => {
|
||||
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// 保存済み calc_settings でページ開時に自動計算してラベル用 calcMatrix を生成
|
||||
const validSettings = plan.calc_settings?.filter((s) => s.param) ?? [];
|
||||
@@ -142,7 +165,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: unknown } };
|
||||
console.error('初期データ取得エラー:', err);
|
||||
alert(`データの読み込みに失敗しました (${err.response?.status ?? 'network error'})\n${JSON.stringify(err.response?.data ?? '')}`);
|
||||
setSaveError(
|
||||
`データの読み込みに失敗しました (${err.response?.status ?? 'network error'}): ${JSON.stringify(err.response?.data ?? '')}`
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -170,6 +195,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 肥料追加・削除
|
||||
const addFertilizer = (fert: Fertilizer) => {
|
||||
if (isConfirmed) return;
|
||||
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||
setPlanFertilizers((prev) => [...prev, fert]);
|
||||
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||
@@ -177,6 +203,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
};
|
||||
|
||||
const removeFertilizer = (id: number) => {
|
||||
if (isConfirmed) return;
|
||||
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||
const dropCol = (m: Matrix): Matrix => {
|
||||
@@ -195,12 +222,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 圃場追加・削除
|
||||
const addField = (field: Field) => {
|
||||
if (isConfirmed) return;
|
||||
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||
setSelectedFields((prev) => [...prev, field]);
|
||||
setShowFieldPicker(false);
|
||||
};
|
||||
|
||||
const removeField = (id: number) => {
|
||||
if (isConfirmed) return;
|
||||
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||
@@ -210,8 +239,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 自動計算
|
||||
const runCalc = async (setting: CalcSetting) => {
|
||||
if (!setting.param) return alert('パラメータを入力してください');
|
||||
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
|
||||
if (isConfirmed) return;
|
||||
if (!setting.param) {
|
||||
setSaveError('パラメータを入力してください');
|
||||
return;
|
||||
}
|
||||
if (selectedFields.length === 0) {
|
||||
setSaveError('対象圃場を選択してください');
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
|
||||
const targetFields = calcNewOnly
|
||||
? selectedFields.filter((f) => {
|
||||
@@ -222,7 +259,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
})
|
||||
: selectedFields;
|
||||
|
||||
if (targetFields.length === 0) return alert('未入力の圃場がありません。「全圃場」で実行してください。');
|
||||
if (targetFields.length === 0) {
|
||||
setSaveError('未入力の圃場がありません。「全圃場」で実行してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post('/fertilizer/calculate/', {
|
||||
@@ -246,7 +286,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
// adjusted は保持する(テキストボックスにDB/確定値を維持し、ラベルに計算結果を表示)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
alert(err.response?.data?.error ?? '計算に失敗しました');
|
||||
setSaveError(err.response?.data?.error ?? '計算に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,6 +298,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── セル更新(adjusted を更新)
|
||||
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||
if (isConfirmed) return;
|
||||
setAdjusted((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!next[fieldId]) next[fieldId] = {};
|
||||
@@ -268,6 +309,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
|
||||
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
||||
const roundColumn = (fertId: number) => {
|
||||
if (isConfirmed) return;
|
||||
if (roundedColumns.has(fertId)) {
|
||||
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
||||
setAdjusted((prev) => {
|
||||
@@ -318,10 +360,30 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
|
||||
|
||||
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
|
||||
const getNumericValue = (value: string | null | undefined) => {
|
||||
const parsed = parseFloat(value ?? '0');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
const getStockInfo = (fertilizer: Fertilizer) =>
|
||||
fertilizer.material_id ? stockByMaterialId[fertilizer.material_id] ?? null : null;
|
||||
const getPlanAvailableStock = (fertilizer: Fertilizer) => {
|
||||
const stock = getStockInfo(fertilizer);
|
||||
if (!stock) return null;
|
||||
return getNumericValue(stock.available_stock) + (initialPlanTotals[fertilizer.id] ?? 0);
|
||||
};
|
||||
const getPlanShortage = (fertilizer: Fertilizer) => {
|
||||
const available = getPlanAvailableStock(fertilizer);
|
||||
if (available === null) return 0;
|
||||
return Math.max(colTotal(fertilizer.id) - available, 0);
|
||||
};
|
||||
|
||||
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
||||
const handleSave = async () => {
|
||||
setSaveError(null);
|
||||
if (isConfirmed) {
|
||||
setSaveError('確定済みの施肥計画は編集できません。');
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
||||
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
||||
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
|
||||
@@ -362,9 +424,35 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 確定取消
|
||||
const handleUnconfirm = async () => {
|
||||
if (!planId) return;
|
||||
setSaveError(null);
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
|
||||
setIsConfirmed(false);
|
||||
setConfirmedAt(null);
|
||||
// 引当が再作成されるので在庫情報を再取得
|
||||
const stockRes = await api.get('/materials/fertilizer-stock/');
|
||||
setStockByMaterialId(
|
||||
stockRes.data.reduce(
|
||||
(acc: Record<number, StockSummary>, summary: StockSummary) => {
|
||||
acc[summary.material_id] = summary;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSaveError('確定取消に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PDF出力
|
||||
const handlePdf = async () => {
|
||||
if (!planId) return;
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||
@@ -374,7 +462,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
alert('PDF出力に失敗しました');
|
||||
console.error(e);
|
||||
setSaveError('PDF出力に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,6 +495,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNew && isConfirmed && (
|
||||
<button
|
||||
onClick={handleUnconfirm}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
確定取消
|
||||
</button>
|
||||
)}
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={handlePdf}
|
||||
@@ -417,11 +515,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
disabled={saving || isConfirmed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,6 +533,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConfirmed && (
|
||||
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
|
||||
<span className="font-bold shrink-0">i</span>
|
||||
<span>
|
||||
この施肥計画は散布確定済みです。
|
||||
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本情報 */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
@@ -444,6 +552,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||
disabled={isConfirmed}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -452,6 +561,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
disabled={isConfirmed}
|
||||
>
|
||||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||
</select>
|
||||
@@ -462,6 +572,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={varietyId}
|
||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||
disabled={isConfirmed}
|
||||
>
|
||||
<option value="">品種を選択</option>
|
||||
{crops.map((crop) => (
|
||||
@@ -487,7 +598,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowFieldPicker(true)}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||
disabled={isConfirmed}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />圃場を追加
|
||||
</button>
|
||||
@@ -504,7 +616,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
|
||||
>
|
||||
{f.name}({f.area_tan}反)
|
||||
<button onClick={() => removeField(f.id)} className="text-green-400 hover:text-red-500">
|
||||
<button
|
||||
onClick={() => removeField(f.id)}
|
||||
disabled={isConfirmed}
|
||||
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -522,13 +638,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
type="checkbox"
|
||||
checked={calcNewOnly}
|
||||
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
||||
disabled={isConfirmed}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
未入力圃場のみ
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowFertPicker(true)}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||
disabled={isConfirmed}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />肥料を追加
|
||||
</button>
|
||||
@@ -550,6 +668,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
value={setting.method}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||
disabled={isConfirmed}
|
||||
>
|
||||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
@@ -562,17 +681,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
value={setting.param}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||
placeholder="値"
|
||||
disabled={isConfirmed}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||
<button
|
||||
onClick={() => runCalc(setting)}
|
||||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100"
|
||||
disabled={isConfirmed}
|
||||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Calculator className="h-3 w-3" />計算
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFertilizer(fert.id)}
|
||||
className="ml-auto text-gray-300 hover:text-red-500"
|
||||
disabled={isConfirmed}
|
||||
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -593,18 +715,44 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">面積(反)</th>
|
||||
{planFertilizers.map((f) => {
|
||||
const isRounded = roundedColumns.has(f.id);
|
||||
const stock = getStockInfo(f);
|
||||
const planAvailable = getPlanAvailableStock(f);
|
||||
const shortage = getPlanShortage(f);
|
||||
return (
|
||||
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
|
||||
{f.name}
|
||||
<div>{f.name}</div>
|
||||
{stock ? (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
|
||||
<div className="text-gray-500">
|
||||
在庫 {stock.current_stock}{stock.stock_unit_display}
|
||||
{planAvailable !== null && (
|
||||
<span className="ml-1">
|
||||
/ 利用可能 {planAvailable.toFixed(2)}{stock.stock_unit_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={shortage > 0 ? 'text-red-600' : 'text-gray-500'}>
|
||||
計画計 {colTotal(f.id).toFixed(2)}{stock.stock_unit_display}
|
||||
{shortage > 0 && (
|
||||
<span className="ml-1">/ 不足 {shortage.toFixed(2)}{stock.stock_unit_display}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[11px] font-normal text-amber-600">
|
||||
在庫情報なし
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
|
||||
(袋)
|
||||
<button
|
||||
onClick={() => roundColumn(f.id)}
|
||||
disabled={isConfirmed}
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
||||
isRounded
|
||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||||
}`}
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||
>
|
||||
{isRounded ? '↩' : '≈'}
|
||||
@@ -641,6 +789,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
value={inputValue}
|
||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||
placeholder="-"
|
||||
disabled={isConfirmed}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -689,7 +838,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between"
|
||||
disabled={isConfirmed}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
<span className="text-gray-400">{f.area_tan}反</span>
|
||||
@@ -703,7 +853,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between"
|
||||
disabled={isConfirmed}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
<span className="text-gray-400">{f.area_tan}反</span>
|
||||
@@ -730,7 +881,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addFertilizer(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
|
||||
disabled={isConfirmed}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="font-medium">{f.name}</span>
|
||||
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||||
|
||||
@@ -15,6 +15,8 @@ const emptyForm = (): Omit<Fertilizer, 'id'> => ({
|
||||
phosphorus_pct: null,
|
||||
potassium_pct: null,
|
||||
notes: null,
|
||||
material: null,
|
||||
material_id: null,
|
||||
});
|
||||
|
||||
export default function FertilizerMastersPage() {
|
||||
@@ -55,6 +57,8 @@ export default function FertilizerMastersPage() {
|
||||
phosphorus_pct: f.phosphorus_pct,
|
||||
potassium_pct: f.potassium_pct,
|
||||
notes: f.notes,
|
||||
material: f.material,
|
||||
material_id: f.material_id,
|
||||
});
|
||||
setEditingId(f.id);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
|
||||
|
||||
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { FertilizationPlan } from '@/types';
|
||||
@@ -21,6 +23,8 @@ export default function FertilizerPage() {
|
||||
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('fertilizerYear', String(year));
|
||||
@@ -41,6 +45,7 @@ export default function FertilizerPage() {
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
setDeleteError(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.delete(`/fertilizer/plans/${id}/`);
|
||||
await fetchPlans();
|
||||
@@ -50,7 +55,19 @@ export default function FertilizerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnconfirm = async (id: number, name: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setActionError(`「${name}」の確定取消に失敗しました`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (id: number, name: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||
@@ -61,7 +78,7 @@ export default function FertilizerPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('PDF出力に失敗しました');
|
||||
setActionError('PDF出力に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +132,14 @@ export default function FertilizerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
<span className="font-bold shrink-0">⚠</span>
|
||||
<span>{actionError}</span>
|
||||
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
@@ -135,6 +160,7 @@ export default function FertilizerPage() {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">計画名</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">作物 / 品種</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">状態</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-700">圃場数</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-700">肥料種数</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
@@ -142,15 +168,52 @@ export default function FertilizerPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{plan.name}</td>
|
||||
<tr
|
||||
key={plan.id}
|
||||
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{plan.name}</span>
|
||||
{plan.is_confirmed && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
確定済み
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{plan.crop_name} / {plan.variety_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{plan.is_confirmed
|
||||
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
|
||||
: '未確定'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{!plan.is_confirmed ? (
|
||||
<button
|
||||
onClick={() => setConfirmTarget(plan)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
|
||||
title="散布確定"
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
散布確定
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleUnconfirm(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
|
||||
title="確定取消"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
確定取消
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handlePdf(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
|
||||
@@ -184,6 +247,13 @@ export default function FertilizerPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmSpreadingModal
|
||||
isOpen={confirmTarget !== null}
|
||||
plan={confirmTarget}
|
||||
onClose={() => setConfirmTarget(null)}
|
||||
onConfirmed={fetchPlans}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
341
frontend/src/app/materials/_components/MaterialForm.tsx
Normal file
341
frontend/src/app/materials/_components/MaterialForm.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import { Material } from '@/types';
|
||||
|
||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
|
||||
|
||||
export interface MaterialFormState {
|
||||
name: string;
|
||||
material_type: Material['material_type'];
|
||||
maker: string;
|
||||
stock_unit: Material['stock_unit'];
|
||||
is_active: boolean;
|
||||
notes: string;
|
||||
fertilizer_profile: {
|
||||
capacity_kg: string;
|
||||
nitrogen_pct: string;
|
||||
phosphorus_pct: string;
|
||||
potassium_pct: string;
|
||||
};
|
||||
pesticide_profile: {
|
||||
registration_no: string;
|
||||
formulation: string;
|
||||
usage_unit: string;
|
||||
dilution_ratio: string;
|
||||
active_ingredient: string;
|
||||
category: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MaterialFormProps {
|
||||
tab: MaterialTab;
|
||||
form: MaterialFormState;
|
||||
saving: boolean;
|
||||
onBaseFieldChange: (
|
||||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||||
value: string | boolean
|
||||
) => void;
|
||||
onFertilizerFieldChange: (
|
||||
field: keyof MaterialFormState['fertilizer_profile'],
|
||||
value: string
|
||||
) => void;
|
||||
onPesticideFieldChange: (
|
||||
field: keyof MaterialFormState['pesticide_profile'],
|
||||
value: string
|
||||
) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500';
|
||||
|
||||
export default function MaterialForm({
|
||||
tab,
|
||||
form,
|
||||
saving,
|
||||
onBaseFieldChange,
|
||||
onFertilizerFieldChange,
|
||||
onPesticideFieldChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: MaterialFormProps) {
|
||||
if (tab === 'fertilizer') {
|
||||
return (
|
||||
<tr className="bg-green-50">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.name}
|
||||
onChange={(e) => onBaseFieldChange('name', e.target.value)}
|
||||
placeholder="資材名"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.maker}
|
||||
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
|
||||
placeholder="メーカー"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={form.fertilizer_profile.capacity_kg}
|
||||
onChange={(e) => onFertilizerFieldChange('capacity_kg', e.target.value)}
|
||||
placeholder="kg"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.fertilizer_profile.nitrogen_pct}
|
||||
onChange={(e) => onFertilizerFieldChange('nitrogen_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.fertilizer_profile.phosphorus_pct}
|
||||
onChange={(e) => onFertilizerFieldChange('phosphorus_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.fertilizer_profile.potassium_pct}
|
||||
onChange={(e) => onFertilizerFieldChange('potassium_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<StockUnitSelect
|
||||
value={form.stock_unit}
|
||||
onChange={(value) => onBaseFieldChange('stock_unit', value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.notes}
|
||||
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
|
||||
placeholder="備考"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === 'pesticide') {
|
||||
return (
|
||||
<tr className="bg-green-50">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.name}
|
||||
onChange={(e) => onBaseFieldChange('name', e.target.value)}
|
||||
placeholder="資材名"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.maker}
|
||||
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
|
||||
placeholder="メーカー"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.pesticide_profile.registration_no}
|
||||
onChange={(e) => onPesticideFieldChange('registration_no', e.target.value)}
|
||||
placeholder="登録番号"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.pesticide_profile.formulation}
|
||||
onChange={(e) => onPesticideFieldChange('formulation', e.target.value)}
|
||||
placeholder="剤型"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.pesticide_profile.active_ingredient}
|
||||
onChange={(e) => onPesticideFieldChange('active_ingredient', e.target.value)}
|
||||
placeholder="有効成分"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.pesticide_profile.category}
|
||||
onChange={(e) => onPesticideFieldChange('category', e.target.value)}
|
||||
placeholder="分類"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<StockUnitSelect
|
||||
value={form.stock_unit}
|
||||
onChange={(value) => onBaseFieldChange('stock_unit', value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.notes}
|
||||
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
|
||||
placeholder="備考"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.name}
|
||||
onChange={(e) => onBaseFieldChange('name', e.target.value)}
|
||||
placeholder="資材名"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.material_type}
|
||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
||||
>
|
||||
<option value="other">その他</option>
|
||||
<option value="seedling">種苗</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.maker}
|
||||
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
|
||||
placeholder="メーカー"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<StockUnitSelect
|
||||
value={form.stock_unit}
|
||||
onChange={(value) => onBaseFieldChange('stock_unit', value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={form.notes}
|
||||
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
|
||||
placeholder="備考"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButtons({
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="text-green-600 transition hover:text-green-800 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 transition hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockUnitSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Material['stock_unit'];
|
||||
onChange: (value: Material['stock_unit']) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as Material['stock_unit'])}
|
||||
>
|
||||
<option value="bag">袋</option>
|
||||
<option value="bottle">本</option>
|
||||
<option value="kg">kg</option>
|
||||
<option value="liter">L</option>
|
||||
<option value="piece">個</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
167
frontend/src/app/materials/_components/StockOverview.tsx
Normal file
167
frontend/src/app/materials/_components/StockOverview.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Clock3, Download, Upload } from 'lucide-react';
|
||||
|
||||
import { StockSummary, StockTransaction } from '@/types';
|
||||
|
||||
interface StockOverviewProps {
|
||||
loading: boolean;
|
||||
items: StockSummary[];
|
||||
expandedMaterialId: number | null;
|
||||
historyLoadingId: number | null;
|
||||
histories: Record<number, StockTransaction[]>;
|
||||
onOpenTransaction: (
|
||||
materialId: number,
|
||||
transactionType: StockTransaction['transaction_type']
|
||||
) => void;
|
||||
onToggleHistory: (materialId: number) => void;
|
||||
}
|
||||
|
||||
export default function StockOverview({
|
||||
loading,
|
||||
items,
|
||||
expandedMaterialId,
|
||||
historyLoadingId,
|
||||
histories,
|
||||
onOpenTransaction,
|
||||
onToggleHistory,
|
||||
}: StockOverviewProps) {
|
||||
if (loading) {
|
||||
return <p className="text-sm text-gray-500">読み込み中...</p>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-6 py-12 text-center text-gray-500">
|
||||
表示できる在庫データがありません。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">資材名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">種別</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">現在庫</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">最終入出庫日</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map((item) => {
|
||||
const isExpanded = expandedMaterialId === item.material_id;
|
||||
const history = histories[item.material_id] ?? [];
|
||||
|
||||
return (
|
||||
<Fragment key={item.material_id}>
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{item.name}</span>
|
||||
{!item.is_active && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
無効
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{item.material_type_display}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{item.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="font-semibold text-gray-900">
|
||||
在庫 {item.current_stock}
|
||||
{item.reserved_stock !== '0.000' && (
|
||||
<span className="ml-1 text-xs font-normal text-amber-600">
|
||||
(引当 {item.reserved_stock})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
利用可能 {item.available_stock}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{item.last_transaction_date ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onOpenTransaction(item.material_id, 'purchase')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 transition hover:bg-emerald-50"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
入庫
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenTransaction(item.material_id, 'use')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 px-2.5 py-1.5 text-xs text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
出庫
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleHistory(item.material_id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
履歴
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-gray-50/70">
|
||||
<td colSpan={7} className="px-4 py-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{item.name} の入出庫履歴
|
||||
</h3>
|
||||
{historyLoadingId === item.material_id && (
|
||||
<span className="text-xs text-gray-500">読み込み中...</span>
|
||||
)}
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">履歴はまだありません。</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{history.map((transaction) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="flex flex-col gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-gray-900">
|
||||
{transaction.transaction_type_display}
|
||||
</span>
|
||||
<span>
|
||||
{transaction.quantity} {transaction.stock_unit_display}
|
||||
</span>
|
||||
<span className="text-gray-500">{transaction.occurred_on}</span>
|
||||
</div>
|
||||
<span className="text-gray-500">
|
||||
{transaction.note || '備考なし'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
frontend/src/app/materials/_components/StockTransactionForm.tsx
Normal file
211
frontend/src/app/materials/_components/StockTransactionForm.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import { Material, StockTransaction } from '@/types';
|
||||
|
||||
type TransactionType = StockTransaction['transaction_type'];
|
||||
|
||||
interface StockTransactionFormProps {
|
||||
isOpen: boolean;
|
||||
materials: Material[];
|
||||
presetMaterialId?: number | null;
|
||||
presetTransactionType?: TransactionType | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const transactionOptions: { value: TransactionType; label: string }[] = [
|
||||
{ value: 'purchase', label: '入庫' },
|
||||
{ value: 'use', label: '使用' },
|
||||
{ value: 'adjustment_plus', label: '棚卸増' },
|
||||
{ value: 'adjustment_minus', label: '棚卸減' },
|
||||
{ value: 'discard', label: '廃棄' },
|
||||
];
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function StockTransactionForm({
|
||||
isOpen,
|
||||
materials,
|
||||
presetMaterialId = null,
|
||||
presetTransactionType = null,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: StockTransactionFormProps) {
|
||||
const [materialId, setMaterialId] = useState<string>('');
|
||||
const [transactionType, setTransactionType] = useState<TransactionType>('purchase');
|
||||
const [quantity, setQuantity] = useState('');
|
||||
const [occurredOn, setOccurredOn] = useState(today());
|
||||
const [note, setNote] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
|
||||
setTransactionType(presetTransactionType ?? 'purchase');
|
||||
setQuantity('');
|
||||
setOccurredOn(today());
|
||||
setNote('');
|
||||
setError(null);
|
||||
}, [isOpen, presetMaterialId, presetTransactionType]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!materialId) {
|
||||
setError('資材を選択してください。');
|
||||
return;
|
||||
}
|
||||
if (!quantity || Number(quantity) <= 0) {
|
||||
setError('数量は0より大きい値を入力してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/materials/stock-transactions/', {
|
||||
material: Number(materialId),
|
||||
transaction_type: transactionType,
|
||||
quantity,
|
||||
occurred_on: occurredOn,
|
||||
note,
|
||||
});
|
||||
await onSaved();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: '入出庫の登録に失敗しました。';
|
||||
setError(detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">入出庫登録</h2>
|
||||
<p className="text-sm text-gray-500">在庫の増減を記録します。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">資材</span>
|
||||
<select
|
||||
value={materialId}
|
||||
onChange={(e) => setMaterialId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{materials.map((material) => (
|
||||
<option key={material.id} value={material.id}>
|
||||
{material.name} ({material.material_type_display})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">取引種別</span>
|
||||
<select
|
||||
value={transactionType}
|
||||
onChange={(e) => setTransactionType(e.target.value as TransactionType)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
>
|
||||
{transactionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">数量</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
placeholder="0.000"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">発生日</span>
|
||||
<input
|
||||
type="date"
|
||||
value={occurredOn}
|
||||
onChange={(e) => setOccurredOn(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">備考</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||
placeholder="任意でメモを残せます"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
590
frontend/src/app/materials/masters/page.tsx
Normal file
590
frontend/src/app/materials/masters/page.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import MaterialForm, {
|
||||
MaterialFormState,
|
||||
MaterialTab,
|
||||
} from '../_components/MaterialForm';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Material } from '@/types';
|
||||
|
||||
const tabs: { key: MaterialTab; label: string }[] = [
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||||
name: '',
|
||||
material_type:
|
||||
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
|
||||
maker: '',
|
||||
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
fertilizer_profile: {
|
||||
capacity_kg: '',
|
||||
nitrogen_pct: '',
|
||||
phosphorus_pct: '',
|
||||
potassium_pct: '',
|
||||
},
|
||||
pesticide_profile: {
|
||||
registration_no: '',
|
||||
formulation: '',
|
||||
usage_unit: '',
|
||||
dilution_ratio: '',
|
||||
active_ingredient: '',
|
||||
category: '',
|
||||
},
|
||||
});
|
||||
|
||||
export default function MaterialMastersPage() {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<MaterialTab>('fertilizer');
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId === 'new') {
|
||||
setForm(emptyForm(tab));
|
||||
}
|
||||
}, [tab, editingId]);
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/materials/materials/');
|
||||
setMaterials(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('資材マスタの取得に失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleMaterials = materials.filter((material) => {
|
||||
if (tab === 'misc') {
|
||||
return material.material_type === 'other' || material.material_type === 'seedling';
|
||||
}
|
||||
return material.material_type === tab;
|
||||
});
|
||||
|
||||
const startNew = () => {
|
||||
setError(null);
|
||||
setForm(emptyForm(tab));
|
||||
setEditingId('new');
|
||||
};
|
||||
|
||||
const startEdit = (material: Material) => {
|
||||
setError(null);
|
||||
setForm({
|
||||
name: material.name,
|
||||
material_type: material.material_type,
|
||||
maker: material.maker,
|
||||
stock_unit: material.stock_unit,
|
||||
is_active: material.is_active,
|
||||
notes: material.notes,
|
||||
fertilizer_profile: {
|
||||
capacity_kg: material.fertilizer_profile?.capacity_kg ?? '',
|
||||
nitrogen_pct: material.fertilizer_profile?.nitrogen_pct ?? '',
|
||||
phosphorus_pct: material.fertilizer_profile?.phosphorus_pct ?? '',
|
||||
potassium_pct: material.fertilizer_profile?.potassium_pct ?? '',
|
||||
},
|
||||
pesticide_profile: {
|
||||
registration_no: material.pesticide_profile?.registration_no ?? '',
|
||||
formulation: material.pesticide_profile?.formulation ?? '',
|
||||
usage_unit: material.pesticide_profile?.usage_unit ?? '',
|
||||
dilution_ratio: material.pesticide_profile?.dilution_ratio ?? '',
|
||||
active_ingredient: material.pesticide_profile?.active_ingredient ?? '',
|
||||
category: material.pesticide_profile?.category ?? '',
|
||||
},
|
||||
});
|
||||
setEditingId(material.id);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm(tab));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError('資材名を入力してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
material_type: form.material_type,
|
||||
maker: form.maker,
|
||||
stock_unit: form.stock_unit,
|
||||
is_active: form.is_active,
|
||||
notes: form.notes,
|
||||
fertilizer_profile:
|
||||
form.material_type === 'fertilizer'
|
||||
? {
|
||||
capacity_kg: form.fertilizer_profile.capacity_kg || null,
|
||||
nitrogen_pct: form.fertilizer_profile.nitrogen_pct || null,
|
||||
phosphorus_pct: form.fertilizer_profile.phosphorus_pct || null,
|
||||
potassium_pct: form.fertilizer_profile.potassium_pct || null,
|
||||
}
|
||||
: undefined,
|
||||
pesticide_profile:
|
||||
form.material_type === 'pesticide'
|
||||
? {
|
||||
registration_no: form.pesticide_profile.registration_no,
|
||||
formulation: form.pesticide_profile.formulation,
|
||||
usage_unit: form.pesticide_profile.usage_unit,
|
||||
dilution_ratio: form.pesticide_profile.dilution_ratio,
|
||||
active_ingredient: form.pesticide_profile.active_ingredient,
|
||||
category: form.pesticide_profile.category,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (editingId === 'new') {
|
||||
await api.post('/materials/materials/', payload);
|
||||
} else {
|
||||
await api.put(`/materials/materials/${editingId}/`, payload);
|
||||
}
|
||||
|
||||
await fetchMaterials();
|
||||
setEditingId(null);
|
||||
setForm(emptyForm(tab));
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: '保存に失敗しました。';
|
||||
setError(detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (material: Material) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/materials/materials/${material.id}/`);
|
||||
await fetchMaterials();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const detail =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'response' in e &&
|
||||
typeof e.response === 'object' &&
|
||||
e.response !== null &&
|
||||
'data' in e.response
|
||||
? JSON.stringify(e.response.data)
|
||||
: `「${material.name}」の削除に失敗しました。`;
|
||||
setError(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBaseFieldChange = (
|
||||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||||
value: string | boolean
|
||||
) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFertilizerFieldChange = (
|
||||
field: keyof MaterialFormState['fertilizer_profile'],
|
||||
value: string
|
||||
) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fertilizer_profile: {
|
||||
...prev.fertilizer_profile,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePesticideFieldChange = (
|
||||
field: keyof MaterialFormState['pesticide_profile'],
|
||||
value: string
|
||||
) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
pesticide_profile: {
|
||||
...prev.pesticide_profile,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/materials')}
|
||||
className="text-gray-500 transition hover:text-gray-700"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">資材マスタ管理</h1>
|
||||
<p className="text-sm text-gray-500">資材情報をインラインで編集できます。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startNew}
|
||||
disabled={editingId !== null}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
setTab(item.key);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm(item.key));
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
||||
tab === item.key
|
||||
? 'bg-green-600 text-white shadow-sm'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-400 transition hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">読み込み中...</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{tab === 'fertilizer' && (
|
||||
<FertilizerTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'pesticide' && (
|
||||
<PesticideTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'misc' && (
|
||||
<MiscTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableProps {
|
||||
materials: Material[];
|
||||
editingId: number | 'new' | null;
|
||||
form: MaterialFormState;
|
||||
saving: boolean;
|
||||
onEdit: (material: Material) => void;
|
||||
onDelete: (material: Material) => void;
|
||||
onBaseFieldChange: (
|
||||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||||
value: string | boolean
|
||||
) => void;
|
||||
onFertilizerFieldChange: (
|
||||
field: keyof MaterialFormState['fertilizer_profile'],
|
||||
value: string
|
||||
) => void;
|
||||
onPesticideFieldChange: (
|
||||
field: keyof MaterialFormState['pesticide_profile'],
|
||||
value: string
|
||||
) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function FertilizerTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">資材名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">1袋(kg)</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">窒素(%)</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">リン酸(%)</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">カリ(%)</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">使用中</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{props.editingId === 'new' && <MaterialForm tab="fertilizer" {...props} />}
|
||||
{props.materials.map((material) =>
|
||||
props.editingId === material.id ? (
|
||||
<MaterialForm key={material.id} tab="fertilizer" {...props} />
|
||||
) : (
|
||||
<tr key={material.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">
|
||||
{material.fertilizer_profile?.capacity_kg ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">
|
||||
{material.fertilizer_profile?.nitrogen_pct ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">
|
||||
{material.fertilizer_profile?.phosphorus_pct ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">
|
||||
{material.fertilizer_profile?.potassium_pct ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600">
|
||||
{material.is_active ? '○' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RowActions
|
||||
disabled={props.editingId !== null}
|
||||
onEdit={() => props.onEdit(material)}
|
||||
onDelete={() => props.onDelete(material)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{props.materials.length === 0 && props.editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-gray-400">
|
||||
該当する資材が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function PesticideTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">資材名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">登録番号</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">剤型</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">有効成分</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">分類</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">使用中</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{props.editingId === 'new' && <MaterialForm tab="pesticide" {...props} />}
|
||||
{props.materials.map((material) =>
|
||||
props.editingId === material.id ? (
|
||||
<MaterialForm key={material.id} tab="pesticide" {...props} />
|
||||
) : (
|
||||
<tr key={material.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{material.pesticide_profile?.registration_no || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{material.pesticide_profile?.formulation || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{material.pesticide_profile?.active_ingredient || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{material.pesticide_profile?.category || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600">
|
||||
{material.is_active ? '○' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RowActions
|
||||
disabled={props.editingId !== null}
|
||||
onEdit={() => props.onEdit(material)}
|
||||
onDelete={() => props.onDelete(material)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{props.materials.length === 0 && props.editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-gray-400">
|
||||
該当する資材が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function MiscTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">資材名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">種別</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">使用中</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{props.editingId === 'new' && <MaterialForm tab="misc" {...props} />}
|
||||
{props.materials.map((material) =>
|
||||
props.editingId === material.id ? (
|
||||
<MaterialForm key={material.id} tab="misc" {...props} />
|
||||
) : (
|
||||
<tr key={material.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.material_type_display}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600">
|
||||
{material.is_active ? '○' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RowActions
|
||||
disabled={props.editingId !== null}
|
||||
onEdit={() => props.onEdit(material)}
|
||||
onDelete={() => props.onDelete(material)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{props.materials.length === 0 && props.editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
該当する資材が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({
|
||||
disabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
className="text-gray-400 transition hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className="text-gray-400 transition hover:text-red-600 disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
frontend/src/app/materials/page.tsx
Normal file
208
frontend/src/app/materials/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Package, Plus, Settings2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import StockOverview from './_components/StockOverview';
|
||||
import StockTransactionForm from './_components/StockTransactionForm';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Material, StockSummary, StockTransaction } from '@/types';
|
||||
|
||||
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
|
||||
|
||||
const tabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: 'all', label: '全て' },
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
export default function MaterialsPage() {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<FilterTab>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [summaries, setSummaries] = useState<StockSummary[]>([]);
|
||||
const [histories, setHistories] = useState<Record<number, StockTransaction[]>>({});
|
||||
const [expandedMaterialId, setExpandedMaterialId] = useState<number | null>(null);
|
||||
const [historyLoadingId, setHistoryLoadingId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isTransactionOpen, setIsTransactionOpen] = useState(false);
|
||||
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
|
||||
const [presetTransactionType, setPresetTransactionType] =
|
||||
useState<StockTransaction['transaction_type'] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [materialsRes, summariesRes] = await Promise.all([
|
||||
api.get('/materials/materials/'),
|
||||
api.get('/materials/stock-summary/'),
|
||||
]);
|
||||
setMaterials(materialsRes.data);
|
||||
setSummaries(summariesRes.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('在庫データの取得に失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSummaryOnly = async () => {
|
||||
try {
|
||||
const res = await api.get('/materials/stock-summary/');
|
||||
setSummaries(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('在庫一覧の更新に失敗しました。');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHistory = async (materialId: number) => {
|
||||
if (expandedMaterialId === materialId) {
|
||||
setExpandedMaterialId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedMaterialId(materialId);
|
||||
if (histories[materialId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryLoadingId(materialId);
|
||||
try {
|
||||
const res = await api.get(`/materials/stock-transactions/?material_id=${materialId}`);
|
||||
setHistories((prev) => ({ ...prev, [materialId]: res.data }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('履歴の取得に失敗しました。');
|
||||
} finally {
|
||||
setHistoryLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTransaction = (
|
||||
materialId: number | null,
|
||||
transactionType: StockTransaction['transaction_type'] | null
|
||||
) => {
|
||||
setPresetMaterialId(materialId);
|
||||
setPresetTransactionType(transactionType);
|
||||
setIsTransactionOpen(true);
|
||||
};
|
||||
|
||||
const handleSavedTransaction = async () => {
|
||||
await fetchSummaryOnly();
|
||||
|
||||
if (expandedMaterialId !== null) {
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/materials/stock-transactions/?material_id=${expandedMaterialId}`
|
||||
);
|
||||
setHistories((prev) => ({ ...prev, [expandedMaterialId]: res.data }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredSummaries = summaries.filter((summary) => {
|
||||
if (tab === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (tab === 'misc') {
|
||||
return summary.material_type === 'other' || summary.material_type === 'seedling';
|
||||
}
|
||||
return summary.material_type === tab;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 rounded-3xl bg-gradient-to-r from-emerald-600 via-green-600 to-lime-500 px-6 py-7 text-white shadow-lg sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
Materials Inventory
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">在庫管理</h1>
|
||||
<p className="mt-2 text-sm text-emerald-50">
|
||||
資材ごとの現在庫と入出庫履歴をまとめて確認できます。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/materials/masters')}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/30 bg-white/10 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/20"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
資材マスタ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenTransaction(null, null)}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-medium text-green-700 transition hover:bg-green-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
入出庫登録
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-400 transition hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
||||
tab === item.key
|
||||
? 'bg-green-600 text-white shadow-sm'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StockOverview
|
||||
loading={loading}
|
||||
items={filteredSummaries}
|
||||
expandedMaterialId={expandedMaterialId}
|
||||
historyLoadingId={historyLoadingId}
|
||||
histories={histories}
|
||||
onOpenTransaction={handleOpenTransaction}
|
||||
onToggleHistory={handleToggleHistory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StockTransactionForm
|
||||
isOpen={isTransactionOpen}
|
||||
materials={materials}
|
||||
presetMaterialId={presetMaterialId}
|
||||
presetTransactionType={presetTransactionType}
|
||||
onClose={() => setIsTransactionOpen(false)}
|
||||
onSaved={handleSavedTransaction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical } from 'lucide-react';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -133,6 +133,17 @@ export default function Navbar() {
|
||||
<FlaskConical className="h-4 w-4 mr-2" />
|
||||
分配計画
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
||||
@@ -65,6 +65,72 @@ export interface Fertilizer {
|
||||
phosphorus_pct: string | null;
|
||||
potassium_pct: string | null;
|
||||
notes: string | null;
|
||||
material: number | null;
|
||||
material_id: number | null;
|
||||
}
|
||||
|
||||
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' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||||
transaction_type_display: string;
|
||||
quantity: string;
|
||||
stock_unit: string;
|
||||
stock_unit_display: string;
|
||||
occurred_on: string;
|
||||
note: string;
|
||||
fertilization_plan: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface FertilizationEntry {
|
||||
@@ -88,6 +154,8 @@ export interface FertilizationPlan {
|
||||
entries: FertilizationEntry[];
|
||||
field_count: number;
|
||||
fertilizer_count: number;
|
||||
is_confirmed: boolean;
|
||||
confirmed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
1899
改善案/在庫管理機能実装案.md
Normal file
1899
改善案/在庫管理機能実装案.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user