Compare commits
6 Commits
adb235250e
...
b855608084
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b855608084 | ||
|
|
cfd67e0d55 | ||
|
|
8ac3a00737 | ||
|
|
f207f5de27 | ||
|
|
371e40236c | ||
|
|
6e99164e3f |
@@ -28,7 +28,10 @@
|
|||||||
"Bash(docker compose exec:*)",
|
"Bash(docker compose exec:*)",
|
||||||
"Bash(docker-compose restart:*)",
|
"Bash(docker-compose restart:*)",
|
||||||
"Bash(TOKEN=\"15c19c3c-3476-4177-8351-3b545c1e51d1\")",
|
"Bash(TOKEN=\"15c19c3c-3476-4177-8351-3b545c1e51d1\")",
|
||||||
"Bash(ssh:*)"
|
"Bash(ssh:*)",
|
||||||
|
"Bash(claude mcp list)",
|
||||||
|
"Bash(claude mcp get trilium)",
|
||||||
|
"Bash(claude mcp get gitea)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
CLAUDE.md
@@ -56,6 +56,9 @@
|
|||||||
```
|
```
|
||||||
keinasystem_t02/
|
keinasystem_t02/
|
||||||
├── CLAUDE.md # このファイル(Claude向けガイド)
|
├── CLAUDE.md # このファイル(Claude向けガイド)
|
||||||
|
├── .cursor/
|
||||||
|
│ └── rules/
|
||||||
|
│ └── 30_Cursorガイド.md # Cursor専用ガイド
|
||||||
├── document/ # 詳細設計書(人間向け)
|
├── document/ # 詳細設計書(人間向け)
|
||||||
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
|
||||||
│ ├── 01_プロダクトビジョン.md
|
│ ├── 01_プロダクトビジョン.md
|
||||||
@@ -93,6 +96,7 @@ keinasystem_t02/
|
|||||||
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
|
||||||
│ ├── history/ # メール処理履歴
|
│ ├── history/ # メール処理履歴
|
||||||
│ └── rules/ # 送信者ルール管理
|
│ └── rules/ # 送信者ルール管理
|
||||||
|
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
|
||||||
└── settings/
|
└── settings/
|
||||||
└── password/ # パスワード変更
|
└── password/ # パスワード変更
|
||||||
```
|
```
|
||||||
@@ -167,6 +171,25 @@ WeatherRecord (日次気象記録)
|
|||||||
└── pressure_min (最低気圧hPa)
|
└── pressure_min (最低気圧hPa)
|
||||||
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
||||||
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
||||||
|
|
||||||
|
Fertilizer (肥料マスタ)
|
||||||
|
├── name(肥料名、必須・unique)
|
||||||
|
├── maker(メーカー、任意)
|
||||||
|
├── capacity_kg(1袋重量kg、任意)
|
||||||
|
├── nitrogen_pct / phosphorus_pct / potassium_pct(成分%、任意)
|
||||||
|
└── notes(備考、任意)
|
||||||
|
|
||||||
|
FertilizationPlan (施肥計画)
|
||||||
|
├── name(計画名)
|
||||||
|
├── year(年度)
|
||||||
|
└── variety (FK to plans.Variety)
|
||||||
|
|
||||||
|
FertilizationEntry (施肥エントリ・中間テーブル)
|
||||||
|
├── plan (FK to FertilizationPlan)
|
||||||
|
├── field (FK to fields.Field)
|
||||||
|
├── fertilizer (FK to Fertilizer)
|
||||||
|
├── bags(袋数、Decimal)
|
||||||
|
└── unique_together = ['plan', 'field', 'fertilizer']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重要な設計判断
|
### 重要な設計判断
|
||||||
@@ -280,10 +303,18 @@ WeatherRecord (日次気象記録)
|
|||||||
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度(GDD)
|
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度(GDD)
|
||||||
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
|
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
|
||||||
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
|
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
|
||||||
- Windmill フロー: `u/admin/weather_sync.flow`(ローカル作成済み、本番デプロイ要)
|
- Windmill フロー: `f/weather/weather_sync`(本番稼働中、毎朝6時 Asia/Tokyo)
|
||||||
- `Crop.base_temp`(GDD計算の基準温度、default=0.0℃)をCropモデルに追加
|
- `Crop.base_temp`(GDD計算の基準温度、default=0.0℃)をCropモデルに追加
|
||||||
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
|
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
|
||||||
|
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts)
|
||||||
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
||||||
|
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
||||||
|
10. **施肥計画機能**:
|
||||||
|
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
||||||
|
- API(JWT認証): `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
|
||||||
|
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
|
||||||
|
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new`・`/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
|
||||||
|
- スコープ外(将来): 購入管理、配置計画
|
||||||
|
|
||||||
### 🚧 既知の課題・技術的負債
|
### 🚧 既知の課題・技術的負債
|
||||||
|
|
||||||
@@ -340,9 +371,11 @@ Phase 2 のタスクに進む段階。
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ⚠️ --env-file .env.production を必ず付けること(省略するとSECRET_KEYが空でbackendが起動しない)
|
# ⚠️ --env-file .env.production を必ず付けること(省略するとSECRET_KEYが空でbackendが起動しない)
|
||||||
ssh keinafarm-claude 'cd /home/akira/keinasystem_t02 && \
|
# ⚠️ 本番ファイルは keinasystem ユーザー所有。git pull は sudo -u keinasystem で実行
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env.production build && \
|
ssh keinafarm-claude 'sudo -u keinasystem git -C /home/keinasystem/keinasystem_t02 pull origin main && \
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
|
cd /home/keinasystem/keinasystem_t02 && \
|
||||||
|
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production build && \
|
||||||
|
sudo -u keinasystem docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
|
||||||
```
|
```
|
||||||
|
|
||||||
### マイグレーションエラー
|
### マイグレーションエラー
|
||||||
@@ -381,6 +414,7 @@ docker-compose exec backend python manage.py migrate
|
|||||||
|
|
||||||
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
|
||||||
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
|
||||||
|
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
|
||||||
|
|
||||||
### 設計ドキュメント(プロジェクト横断)
|
### 設計ドキュメント(プロジェクト横断)
|
||||||
|
|
||||||
@@ -405,7 +439,9 @@ docker-compose exec backend python manage.py migrate
|
|||||||
|
|
||||||
## 📝 更新履歴
|
## 📝 更新履歴
|
||||||
|
|
||||||
- 2026-02-28: 気象データ基盤を実装。`apps/weather` Django app(WeatherRecord, GDD API, 類似年分析API)、Windmill フロー `u/admin/weather_sync.flow`、管理コマンド `fetch_weather`。`Crop.base_temp` 追加(GDD基準温度)。初回データ投入は `fetch_weather --full`
|
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
|
||||||
|
- 2026-03-01: 施肥計画機能を実装。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。スコープ外: 購入管理・配置計画
|
||||||
|
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
|
||||||
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
||||||
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
||||||
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
||||||
|
|||||||
0
backend/apps/fertilizer/__init__.py
Normal file
19
backend/apps/fertilizer/admin.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Fertilizer)
|
||||||
|
class FertilizerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'maker', 'capacity_kg', 'nitrogen_pct']
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntryInline(admin.TabularInline):
|
||||||
|
model = FertilizationEntry
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FertilizationPlan)
|
||||||
|
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'year', 'variety']
|
||||||
|
list_filter = ['year']
|
||||||
|
inlines = [FertilizationEntryInline]
|
||||||
7
backend/apps/fertilizer/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.fertilizer'
|
||||||
|
verbose_name = '施肥計画'
|
||||||
67
backend/apps/fertilizer/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-03-01 02:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fields', '0006_e1c_chusankan_17_fields'),
|
||||||
|
('plans', '0004_crop_base_temp'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Fertilizer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True, verbose_name='肥料名')),
|
||||||
|
('maker', models.CharField(blank=True, max_length=100, null=True, verbose_name='メーカー')),
|
||||||
|
('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='カリ含有率(%)')),
|
||||||
|
('notes', models.TextField(blank=True, null=True, verbose_name='備考')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '肥料マスタ',
|
||||||
|
'verbose_name_plural': '肥料マスタ',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FertilizationPlan',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||||
|
('year', models.IntegerField(verbose_name='年度')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='fertilization_plans', to='plans.variety', verbose_name='品種')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '施肥計画',
|
||||||
|
'verbose_name_plural': '施肥計画',
|
||||||
|
'ordering': ['-year', 'variety'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FertilizationEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bags', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='袋数')),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fields.field', verbose_name='圃場')),
|
||||||
|
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='fertilizer.fertilizationplan')),
|
||||||
|
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '施肥エントリ',
|
||||||
|
'verbose_name_plural': '施肥エントリ',
|
||||||
|
'ordering': ['field', 'fertilizer'],
|
||||||
|
'unique_together': {('plan', 'field', 'fertilizer')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/fertilizer/migrations/__init__.py
Normal file
69
backend/apps/fertilizer/models.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Fertilizer(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True, verbose_name='肥料名')
|
||||||
|
maker = models.CharField(max_length=100, blank=True, null=True, verbose_name='メーカー')
|
||||||
|
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='カリ含有率(%)'
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '肥料マスタ'
|
||||||
|
verbose_name_plural = '肥料マスタ'
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlan(models.Model):
|
||||||
|
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||||
|
year = models.IntegerField(verbose_name='年度')
|
||||||
|
variety = models.ForeignKey(
|
||||||
|
'plans.Variety', on_delete=models.PROTECT,
|
||||||
|
related_name='fertilization_plans', verbose_name='品種'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '施肥計画'
|
||||||
|
verbose_name_plural = '施肥計画'
|
||||||
|
ordering = ['-year', 'variety']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.year} {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntry(models.Model):
|
||||||
|
"""圃場 × 肥料 × 袋数 の中間テーブル"""
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
FertilizationPlan, on_delete=models.CASCADE, related_name='entries'
|
||||||
|
)
|
||||||
|
field = models.ForeignKey(
|
||||||
|
'fields.Field', on_delete=models.CASCADE, verbose_name='圃場'
|
||||||
|
)
|
||||||
|
fertilizer = models.ForeignKey(
|
||||||
|
Fertilizer, on_delete=models.CASCADE, verbose_name='肥料'
|
||||||
|
)
|
||||||
|
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '施肥エントリ'
|
||||||
|
verbose_name_plural = '施肥エントリ'
|
||||||
|
unique_together = [['plan', 'field', 'fertilizer']]
|
||||||
|
ordering = ['field', 'fertilizer']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
|
||||||
81
backend/apps/fertilizer/serializers.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Fertilizer
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||||
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
|
field_area_tan = serializers.DecimalField(
|
||||||
|
source='field.area_tan', max_digits=6, decimal_places=4, read_only=True
|
||||||
|
)
|
||||||
|
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationEntry
|
||||||
|
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||||
|
variety_name = serializers.SerializerMethodField()
|
||||||
|
crop_name = serializers.SerializerMethodField()
|
||||||
|
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||||
|
field_count = serializers.SerializerMethodField()
|
||||||
|
fertilizer_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationPlan
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||||
|
'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_variety_name(self, obj):
|
||||||
|
return obj.variety.name
|
||||||
|
|
||||||
|
def get_crop_name(self, obj):
|
||||||
|
return obj.variety.crop.name
|
||||||
|
|
||||||
|
def get_field_count(self, obj):
|
||||||
|
return obj.entries.values('field').distinct().count()
|
||||||
|
|
||||||
|
def get_fertilizer_count(self, obj):
|
||||||
|
return obj.entries.values('fertilizer').distinct().count()
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
|
"""保存用(entries を一括で受け取る)"""
|
||||||
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationPlan
|
||||||
|
fields = ['id', 'name', 'year', 'variety', 'entries']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', [])
|
||||||
|
plan = FertilizationPlan.objects.create(**validated_data)
|
||||||
|
self._save_entries(plan, entries_data)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
if entries_data is not None:
|
||||||
|
instance.entries.all().delete()
|
||||||
|
self._save_entries(instance, entries_data)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def _save_entries(self, plan, entries_data):
|
||||||
|
for entry in entries_data:
|
||||||
|
FertilizationEntry.objects.create(
|
||||||
|
plan=plan,
|
||||||
|
field_id=entry['field_id'],
|
||||||
|
fertilizer_id=entry['fertilizer_id'],
|
||||||
|
bags=entry['bags'],
|
||||||
|
)
|
||||||
58
backend/apps/fertilizer/templates/fertilizer/pdf.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 15mm; }
|
||||||
|
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
|
||||||
|
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
|
||||||
|
.subtitle { text-align: center; font-size: 10pt; color: #555; margin-bottom: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||||
|
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
|
||||||
|
th { background: #e8f5e9; text-align: center; }
|
||||||
|
.col-name { text-align: left; }
|
||||||
|
.col-area { text-align: right; }
|
||||||
|
tr.total-row { font-weight: bold; background: #f5f5f5; }
|
||||||
|
.zero { color: #bbb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>施肥計画書</h1>
|
||||||
|
<p class="subtitle">{{ plan.year }}年度 {{ plan.variety.crop.name }} / {{ plan.variety.name }} 「{{ plan.name }}」</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">圃場名</th>
|
||||||
|
<th class="col-area">面積(反)</th>
|
||||||
|
{% for fert in fertilizers %}
|
||||||
|
<th>{{ fert.name }}<br><small>(袋)</small></th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>合計袋数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="col-name">{{ row.field.name }}</td>
|
||||||
|
<td class="col-area">{{ row.field.area_tan }}</td>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td class="col-name">合計</td>
|
||||||
|
<td></td>
|
||||||
|
{% for total in fert_totals %}
|
||||||
|
<td>{{ total }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>{{ grand_total }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
backend/apps/fertilizer/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||||
|
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||||
|
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||||
|
]
|
||||||
196
backend/apps/fertilizer/views.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
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.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.plans.models import Plan, Variety
|
||||||
|
from .models import Fertilizer, FertilizationPlan
|
||||||
|
from .serializers import (
|
||||||
|
FertilizerSerializer,
|
||||||
|
FertilizationPlanSerializer,
|
||||||
|
FertilizationPlanWriteSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
queryset = Fertilizer.objects.all()
|
||||||
|
serializer_class = FertilizerSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
||||||
|
'entries', 'entries__field', 'entries__fertilizer'
|
||||||
|
)
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
qs = qs.filter(year=year)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ['create', 'update', 'partial_update']:
|
||||||
|
return FertilizationPlanWriteSerializer
|
||||||
|
return FertilizationPlanSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def pdf(self, request, pk=None):
|
||||||
|
plan = self.get_object()
|
||||||
|
entries = plan.entries.select_related('field', 'fertilizer').order_by(
|
||||||
|
'field__display_order', 'field__id', 'fertilizer__name'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 圃場・肥料の一覧を整理
|
||||||
|
fields_map = {}
|
||||||
|
fertilizers_map = {}
|
||||||
|
for entry in entries:
|
||||||
|
fields_map[entry.field_id] = entry.field
|
||||||
|
fertilizers_map[entry.fertilizer_id] = entry.fertilizer
|
||||||
|
|
||||||
|
fields = sorted(fields_map.values(), key=lambda f: (f.display_order, f.id))
|
||||||
|
fertilizers = sorted(fertilizers_map.values(), key=lambda f: f.name)
|
||||||
|
|
||||||
|
# マトリクスデータ生成
|
||||||
|
matrix = {}
|
||||||
|
for entry in entries:
|
||||||
|
matrix[(entry.field_id, entry.fertilizer_id)] = entry.bags
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for field in fields:
|
||||||
|
cells = [matrix.get((field.id, fert.id), '') for fert in fertilizers]
|
||||||
|
total = sum(v for v in cells if v != '')
|
||||||
|
rows.append({
|
||||||
|
'field': field,
|
||||||
|
'cells': cells,
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 肥料ごとの合計
|
||||||
|
fert_totals = []
|
||||||
|
for fert in fertilizers:
|
||||||
|
total = sum(
|
||||||
|
matrix.get((field.id, fert.id), Decimal('0'))
|
||||||
|
for field in fields
|
||||||
|
)
|
||||||
|
fert_totals.append(total)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'plan': plan,
|
||||||
|
'fertilizers': fertilizers,
|
||||||
|
'rows': rows,
|
||||||
|
'fert_totals': fert_totals,
|
||||||
|
'grand_total': sum(fert_totals),
|
||||||
|
}
|
||||||
|
html_string = render_to_string('fertilizer/pdf.html', context)
|
||||||
|
pdf_file = HTML(string=html_string).write_pdf()
|
||||||
|
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateFieldsView(APIView):
|
||||||
|
"""作付け計画から圃場候補を返す"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
year = request.query_params.get('year')
|
||||||
|
variety_id = request.query_params.get('variety_id')
|
||||||
|
if not year or not variety_id:
|
||||||
|
return Response({'error': 'year と variety_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
field_ids = Plan.objects.filter(
|
||||||
|
year=year, variety_id=variety_id
|
||||||
|
).values_list('field_id', flat=True)
|
||||||
|
|
||||||
|
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': f.id,
|
||||||
|
'name': f.name,
|
||||||
|
'area_tan': str(f.area_tan),
|
||||||
|
'area_m2': f.area_m2,
|
||||||
|
'group_name': f.group_name,
|
||||||
|
}
|
||||||
|
for f in fields
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateView(APIView):
|
||||||
|
"""自動計算(保存しない)"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
method = request.data.get('method') # 'nitrogen' | 'even' | 'per_tan'
|
||||||
|
param = request.data.get('param') # 数値パラメータ
|
||||||
|
fertilizer_id = request.data.get('fertilizer_id')
|
||||||
|
field_ids = request.data.get('field_ids', [])
|
||||||
|
|
||||||
|
if not method or param is None or not field_ids:
|
||||||
|
return Response({'error': 'method, param, field_ids が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
param = Decimal(str(param))
|
||||||
|
except InvalidOperation:
|
||||||
|
return Response({'error': 'param は数値で指定してください'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
fields = Field.objects.filter(id__in=field_ids)
|
||||||
|
if not fields.exists():
|
||||||
|
return Response({'error': '圃場が見つかりません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if method == 'per_tan':
|
||||||
|
# 反当袋数配分: S = Sa × A
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param * area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
elif method == 'even':
|
||||||
|
# 在庫/指定数量均等配分: S = (SS / Sum(A)) × A
|
||||||
|
total_area = sum(Decimal(str(f.area_tan)) for f in fields)
|
||||||
|
if total_area == 0:
|
||||||
|
return Response({'error': '圃場の面積が0です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param * area / total_area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
elif method == 'nitrogen':
|
||||||
|
# 反当チッソ成分量配分: S = (Nr / (C × Nd/100)) × A
|
||||||
|
if not fertilizer_id:
|
||||||
|
return Response({'error': 'nitrogen 方式には fertilizer_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
fertilizer = Fertilizer.objects.get(id=fertilizer_id)
|
||||||
|
except Fertilizer.DoesNotExist:
|
||||||
|
return Response({'error': '肥料が見つかりません'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
if not fertilizer.capacity_kg or not fertilizer.nitrogen_pct:
|
||||||
|
return Response(
|
||||||
|
{'error': 'この肥料には1袋重量(kg)と窒素含有率(%)の登録が必要です'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
c = Decimal(str(fertilizer.capacity_kg))
|
||||||
|
nd = Decimal(str(fertilizer.nitrogen_pct))
|
||||||
|
# 1袋あたりの窒素量 (kg)
|
||||||
|
nc = c * nd / Decimal('100')
|
||||||
|
if nc == 0:
|
||||||
|
return Response({'error': '窒素含有量が0のため計算できません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param / nc * area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(results)
|
||||||
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.reports',
|
'apps.reports',
|
||||||
'apps.mail',
|
'apps.mail',
|
||||||
'apps.weather',
|
'apps.weather',
|
||||||
|
'apps.fertilizer',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ urlpatterns = [
|
|||||||
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
|
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
|
||||||
path('api/mail/', include('apps.mail.urls')),
|
path('api/mail/', include('apps.mail.urls')),
|
||||||
path('api/weather/', include('apps.weather.urls')),
|
path('api/weather/', include('apps.weather.urls')),
|
||||||
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ services:
|
|||||||
container_name: keinasystem_frontend
|
container_name: keinasystem_frontend
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_API_URL: http://localhost:8000
|
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
241
document/12_マスタードキュメント_気象データ編.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# 気象データ機能 マスタードキュメント
|
||||||
|
|
||||||
|
> **最終更新**: 2026-02-28
|
||||||
|
> **状態**: 本番稼働中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
Open-Meteo archive API から窪川の気象データを日次取得し、PostgreSQL に蓄積する。
|
||||||
|
農業における積算温度計算・類似年分析・作期の気象振り返りを目的とする。
|
||||||
|
|
||||||
|
- **観測地点**: 窪川 (lat=33.213, lon=133.133)
|
||||||
|
- **データソース**: [Open-Meteo Archive API](https://archive-api.open-meteo.com/v1/archive)(無料)
|
||||||
|
- **蓄積期間**: 2016-01-01 〜 前日(毎朝自動更新)
|
||||||
|
- **Django アプリ**: `backend/apps/weather/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データモデル
|
||||||
|
|
||||||
|
### WeatherRecord (`apps/weather/models.py`)
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| date | DateField (unique) | 日付 |
|
||||||
|
| temp_mean | FloatField nullable | 平均気温 (℃) |
|
||||||
|
| temp_max | FloatField nullable | 最高気温 (℃) |
|
||||||
|
| temp_min | FloatField nullable | 最低気温 (℃) |
|
||||||
|
| sunshine_h | FloatField nullable | 日照時間 (h) |
|
||||||
|
| precip_mm | FloatField nullable | 降水量 (mm) |
|
||||||
|
| wind_max | FloatField nullable | 最大風速 (m/s) |
|
||||||
|
| pressure_min | FloatField nullable | 最低気圧 (hPa) |
|
||||||
|
|
||||||
|
**Crop.base_temp** (`apps/plans/models.py` に追加):
|
||||||
|
- FloatField, default=0.0
|
||||||
|
- GDD(有効積算温度)計算の基準温度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API エンドポイント (`/api/weather/`)
|
||||||
|
|
||||||
|
### POST `/api/weather/sync/`
|
||||||
|
- **認証**: X-API-Key(MAIL_API_KEY 設定値、Windmill と共用)
|
||||||
|
- **用途**: Windmill から日次データを受け取り upsert
|
||||||
|
- **リクエスト**: 単一オブジェクトまたはリスト
|
||||||
|
- **レスポンス**: `{"saved": N}` or `{"saved": N, "errors": [...]}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-02-27",
|
||||||
|
"temp_mean": 8.5, "temp_max": 14.2, "temp_min": 3.1,
|
||||||
|
"sunshine_h": 6.3, "precip_mm": 0.0,
|
||||||
|
"wind_max": 4.2, "pressure_min": 1008.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/weather/records/`
|
||||||
|
- **認証**: JWT
|
||||||
|
- **クエリパラメータ**:
|
||||||
|
- `?year=2025` — 年指定
|
||||||
|
- `?start=2025-05-01&end=2025-09-30` — 日付範囲指定
|
||||||
|
- **レスポンス**: WeatherRecord の配列(date 昇順)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/weather/summary/?year=2025`
|
||||||
|
- **認証**: JWT
|
||||||
|
- **レスポンス**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2025,
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"month": 1,
|
||||||
|
"temp_mean_avg": 5.2, "temp_max_avg": 10.1, "temp_min_avg": 0.8,
|
||||||
|
"precip_total": 45.0, "sunshine_total": 98.3, "wind_max": 9.5,
|
||||||
|
"hot_days": 0, "cold_days": 8, "rainy_days": 12
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"annual": {
|
||||||
|
"temp_mean_avg": 16.1, "precip_total": 2310.0, "sunshine_total": 1850.5,
|
||||||
|
"hot_days": 12, "cold_days": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **hot_days**: 最高気温 ≥ 35℃ の日数
|
||||||
|
- **cold_days**: 最低気温 < 0℃ の日数
|
||||||
|
- **rainy_days**: 降水量 ≥ 1.0mm の日数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/weather/gdd/`
|
||||||
|
- **認証**: JWT
|
||||||
|
- **用途**: 播種日〜現在の有効積算温度(Growing Degree Days)を計算
|
||||||
|
- **クエリパラメータ**:
|
||||||
|
- `?start_date=2025-05-15` (必須) — 起算日
|
||||||
|
- `?base_temp=10` (省略時=0) — 基準温度 ℃
|
||||||
|
- `?end_date=2025-09-30` (省略時=昨日)
|
||||||
|
- **レスポンス**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"start_date": "2025-05-15",
|
||||||
|
"end_date": "2025-09-30",
|
||||||
|
"base_temp": 10.0,
|
||||||
|
"total_gdd": 1342.5,
|
||||||
|
"records": [
|
||||||
|
{"date": "2025-05-15", "temp_mean": 18.2, "daily_gdd": 8.2, "cumulative_gdd": 8.2},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **日積算温度** = max(0, 平均気温 - 基準温度)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/weather/similarity/?year=2026`
|
||||||
|
- **認証**: JWT
|
||||||
|
- **用途**: 今年 1/1〜昨日 の気象パターンと過去年を比較し、類似年 Top3 を返す
|
||||||
|
- **アルゴリズム**: (平均気温, 総降水量, 総日照時間) の正規化ユークリッド距離
|
||||||
|
- **レスポンス**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target_year": 2026,
|
||||||
|
"comparison_period": "1/1〜2/27",
|
||||||
|
"target_features": {"mean_temp": 7.3, "total_precip": 185.0, "total_sunshine": 240.5},
|
||||||
|
"similar_years": [
|
||||||
|
{
|
||||||
|
"year": 2020, "distance": 0.312,
|
||||||
|
"features": {...},
|
||||||
|
"monthly": [...]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 管理コマンド
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全期間取得(初回のみ)
|
||||||
|
docker compose exec backend python manage.py fetch_weather --full
|
||||||
|
|
||||||
|
# 差分取得(最終レコード翌日〜昨日)
|
||||||
|
docker compose exec backend python manage.py fetch_weather
|
||||||
|
|
||||||
|
# 任意期間
|
||||||
|
docker compose exec backend python manage.py fetch_weather --start-date 2025-01-01 --end-date 2025-12-31
|
||||||
|
```
|
||||||
|
|
||||||
|
**仕様**:
|
||||||
|
- 年単位で Open-Meteo API を呼び出し(API 制限回避のため分割)
|
||||||
|
- upsert: 既存データを上書き更新
|
||||||
|
- `--full`: 2016-01-01 から昨日まで(初回投入用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windmill フロー
|
||||||
|
|
||||||
|
| 項目 | 値 |
|
||||||
|
|---|---|
|
||||||
|
| パス | `f/weather/weather_sync` |
|
||||||
|
| スケジュール | `0 0 6 * * *`(毎朝 6:00 Asia/Tokyo) |
|
||||||
|
| スクリプト | `windmill/u/admin/weather_sync.flow/a.inline_script.py` |
|
||||||
|
| 状態 | ✅ 本番稼働中(windmill.keinafarm.net) |
|
||||||
|
|
||||||
|
**使用 Windmill Variables**:
|
||||||
|
|
||||||
|
| 変数名 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `u/admin/KEINASYSTEM_API_KEY` | API キー(メール機能と共用) |
|
||||||
|
| `u/admin/KEINASYSTEM_API_URL` | `https://keinafarm.net` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## フロントエンド画面 (`/weather`)
|
||||||
|
|
||||||
|
### 年別集計モード(デフォルト)
|
||||||
|
|
||||||
|
- 年セレクタ (2016〜現在)
|
||||||
|
- **年間サマリーカード**: 平均気温 / 年間降水量 / 年間日照時間 / 猛暑日数・冬日数
|
||||||
|
- **グラフタブ**: 月別気温折れ線(最高・平均・最低)、月別降水量棒 + 日照時間折れ線(2軸)
|
||||||
|
- **月別サマリータブ**: 12ヶ月のテーブル
|
||||||
|
- **直近14日タブ**: 日次データテーブル(Windmill 同期確認用)
|
||||||
|
|
||||||
|
### 期間指定モード
|
||||||
|
|
||||||
|
- 開始日・終了日の date input + 「表示」ボタン
|
||||||
|
- **期間集計カード**: 期間の平均気温 / 総降水量 / 総日照時間 / 猛暑日・冬日
|
||||||
|
- **グラフタブ**: 日次気温折れ線 + 日次降水量棒+日照折れ線
|
||||||
|
- X軸ラベル自動間引き(30日以内→3日おき、3ヶ月→週1、半年→2週、1年超→月1)
|
||||||
|
- 60日以内はドット表示あり
|
||||||
|
- **一覧タブ**: 日次データテーブル(スクロール対応)
|
||||||
|
|
||||||
|
**使用ライブラリ**: Recharts 3.7.x(`frontend/package.json` に登録済み)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ファイル索引
|
||||||
|
|
||||||
|
| ファイル | 役割 |
|
||||||
|
|---|---|
|
||||||
|
| `backend/apps/weather/models.py` | WeatherRecord モデル |
|
||||||
|
| `backend/apps/weather/views.py` | 5つのAPIビュー |
|
||||||
|
| `backend/apps/weather/urls.py` | URL設定 |
|
||||||
|
| `backend/apps/weather/serializers.py` | Serializer |
|
||||||
|
| `backend/apps/weather/admin.py` | 管理画面登録 |
|
||||||
|
| `backend/apps/weather/migrations/0001_initial.py` | 初回マイグレーション |
|
||||||
|
| `backend/apps/weather/management/commands/fetch_weather.py` | 管理コマンド |
|
||||||
|
| `backend/apps/plans/migrations/0004_crop_base_temp.py` | Crop.base_temp 追加 |
|
||||||
|
| `frontend/src/app/weather/page.tsx` | 気象画面(400行) |
|
||||||
|
| `windmill/u/admin/weather_sync.flow/a.inline_script.py` | Windmill Python スクリプト |
|
||||||
|
| `windmill/u/admin/weather_sync.flow/flow.yaml` | Windmill フロー定義 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 将来計画(Phase 2 以降)
|
||||||
|
|
||||||
|
1. **GDD 到達日予測**: `Crop.base_temp` を使い、播種日から目標GDDに達する日を予測
|
||||||
|
2. **類似年ベースの収穫予測**: 類似年の収穫時期を参考に今年の予測を表示
|
||||||
|
3. **作付け計画との連携**: 作期ごとの気象サマリーを圃場詳細に表示
|
||||||
|
4. **気象アラート**: 猛暑・長雨・強風などの異常気象を検知して通知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- Open-Meteo archive API は**前日まで**のデータしか取得できない(リアルタイム不可)
|
||||||
|
- `pressure_min` は `surface_pressure_min`(地表気圧)。海面更正気圧とは異なる
|
||||||
|
- Open-Meteo の `sunshine_duration` は秒単位 → `sunshine_h` = 秒 ÷ 3600 で変換
|
||||||
455
document/13_マスタードキュメント_施肥計画編.md
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
# マスタードキュメント:施肥計画機能
|
||||||
|
|
||||||
|
> **作成**: 2026-03-01
|
||||||
|
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理)
|
||||||
|
> **実装状況**: 実装完了(commit f207f5d)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
|
||||||
|
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力する。
|
||||||
|
|
||||||
|
### 機能スコープ(IN / OUT)
|
||||||
|
|
||||||
|
| IN(実装済み) | OUT(対象外) |
|
||||||
|
|---|---|
|
||||||
|
| 肥料マスタ管理 | 肥料購入管理 |
|
||||||
|
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て) |
|
||||||
|
| 3方式の自動計算 | 施肥作業の実績記録 |
|
||||||
|
| 作付け計画からの圃場自動取得 | |
|
||||||
|
| PDF出力(圃場×肥料マトリクス表) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## データモデル
|
||||||
|
|
||||||
|
### Fertilizer(肥料マスタ)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| name | varchar(100) | unique, required | 肥料名 |
|
||||||
|
| maker | varchar(100) | nullable | メーカー |
|
||||||
|
| capacity_kg | decimal(8,3) | nullable | 1袋重量(kg) ← nitrogen計算に必須 |
|
||||||
|
| nitrogen_pct | decimal(5,2) | nullable | 窒素含有率(%) ← nitrogen計算に必須 |
|
||||||
|
| phosphorus_pct | decimal(5,2) | nullable | リン酸含有率(%) |
|
||||||
|
| potassium_pct | decimal(5,2) | nullable | カリ含有率(%) |
|
||||||
|
| notes | text | nullable | 備考 |
|
||||||
|
|
||||||
|
### FertilizationPlan(施肥計画)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
|
||||||
|
| year | int | required | 年度 |
|
||||||
|
| variety | FK(plans.Variety) | PROTECT | 品種(≠NULL) |
|
||||||
|
| created_at | datetime | auto | |
|
||||||
|
| updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
### FertilizationEntry(施肥エントリ:圃場×肥料×袋数)
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| plan | FK(FertilizationPlan) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | CASCADE | |
|
||||||
|
| fertilizer | FK(Fertilizer) | CASCADE | |
|
||||||
|
| bags | decimal(8,2) | required | 袋数 |
|
||||||
|
|
||||||
|
- `unique_together = ['plan', 'field', 'fertilizer']`
|
||||||
|
- 順序: `field__display_order, field__id, fertilizer__name`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API エンドポイント
|
||||||
|
|
||||||
|
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
|
||||||
|
|
||||||
|
### 肥料マスタ
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/fertilizer/fertilizers/` | 一覧取得 |
|
||||||
|
| POST | `/api/fertilizer/fertilizers/` | 新規作成 |
|
||||||
|
| GET | `/api/fertilizer/fertilizers/{id}/` | 詳細取得 |
|
||||||
|
| PUT/PATCH | `/api/fertilizer/fertilizers/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/fertilizer/fertilizers/{id}/` | 削除 |
|
||||||
|
|
||||||
|
レスポンス例(Fertilizer):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "コシヒカリ専用一発肥料",
|
||||||
|
"maker": "JA",
|
||||||
|
"capacity_kg": "20.000",
|
||||||
|
"nitrogen_pct": "14.00",
|
||||||
|
"phosphorus_pct": "12.00",
|
||||||
|
"potassium_pct": "12.00",
|
||||||
|
"notes": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 施肥計画
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/fertilizer/plans/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/fertilizer/plans/` | 新規作成(entries 含む) |
|
||||||
|
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得(entries 含む) |
|
||||||
|
| PUT | `/api/fertilizer/plans/{id}/` | 更新(entries 全置換) |
|
||||||
|
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
|
||||||
|
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力(application/pdf) |
|
||||||
|
|
||||||
|
一覧レスポンス例(FertilizationPlan):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "2025年コシヒカリ施肥計画",
|
||||||
|
"year": 2025,
|
||||||
|
"variety": 3,
|
||||||
|
"variety_name": "コシヒカリ",
|
||||||
|
"crop_name": "米",
|
||||||
|
"field_count": 12,
|
||||||
|
"fertilizer_count": 2,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"field": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"field_area_tan": "1.2000",
|
||||||
|
"fertilizer": 1,
|
||||||
|
"fertilizer_name": "コシヒカリ専用一発肥料",
|
||||||
|
"bags": "2.40"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2025-03-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-03-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
POST/PUT リクエスト例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "2025年コシヒカリ施肥計画",
|
||||||
|
"year": 2025,
|
||||||
|
"variety": 3,
|
||||||
|
"entries": [
|
||||||
|
{"field_id": 5, "fertilizer_id": 1, "bags": 2.4},
|
||||||
|
{"field_id": 6, "fertilizer_id": 1, "bags": 1.6}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
PUT 時は entries が全置換(削除→再作成)。entries を省略した場合は既存を維持。
|
||||||
|
|
||||||
|
### 圃場候補取得
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/fertilizer/candidate_fields/?year={year}&variety_id={variety_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
作付け計画(Planモデル)から year + variety で圃場を検索して返す。
|
||||||
|
|
||||||
|
レスポンス例:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"id": 5, "name": "田中上", "area_tan": "1.2000", "area_m2": 1200, "group_name": "田中"},
|
||||||
|
{"id": 6, "name": "田中下", "area_tan": "0.8000", "area_m2": 800, "group_name": "田中"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自動計算
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/fertilizer/calculate/
|
||||||
|
```
|
||||||
|
|
||||||
|
計算結果を返すのみ(DB保存なし)。
|
||||||
|
|
||||||
|
#### 方式 1: per_tan(反当袋数)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "per_tan",
|
||||||
|
"param": 2.0,
|
||||||
|
"field_ids": [5, 6]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
計算式: `bags = Sa × A`(Sa: 反当袋数, A: 圃場面積[反])
|
||||||
|
|
||||||
|
#### 方式 2: even(均等配分)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "even",
|
||||||
|
"param": 50,
|
||||||
|
"field_ids": [5, 6]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
計算式: `bags = (SS / ΣA) × A`(SS: 総袋数, A: 圃場面積[反])
|
||||||
|
|
||||||
|
#### 方式 3: nitrogen(反当チッソ成分量)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "nitrogen",
|
||||||
|
"param": 3.0,
|
||||||
|
"fertilizer_id": 1,
|
||||||
|
"field_ids": [5, 6]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
計算式: `bags = (Nr / (C × Nd/100)) × A`
|
||||||
|
- Nr: 反当チッソ成分量(kg/反)
|
||||||
|
- C: 1袋重量(kg) ← Fertilizer.capacity_kg 必須
|
||||||
|
- Nd: 窒素含有率(%) ← Fertilizer.nitrogen_pct 必須
|
||||||
|
- A: 圃場面積[反]
|
||||||
|
|
||||||
|
nitrogen 方式は capacity_kg・nitrogen_pct が未設定の肥料に対してはエラー(400)。
|
||||||
|
|
||||||
|
レスポンス(共通):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"field_id": 5, "bags": 2.40},
|
||||||
|
{"field_id": 6, "bags": 1.60}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 品種・作物の取得
|
||||||
|
|
||||||
|
品種一覧は既存の plans アプリの CropViewSet を使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/plans/crops/
|
||||||
|
```
|
||||||
|
|
||||||
|
レスポンス例:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "米",
|
||||||
|
"base_temp": "0.0",
|
||||||
|
"varieties": [
|
||||||
|
{"id": 1, "name": "コシヒカリ", "crop": 1},
|
||||||
|
{"id": 2, "name": "ヒノヒカリ", "crop": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: plans アプリの DefaultRouter が `r''` に登録されているため、
|
||||||
|
`/api/plans/get-crops-with-varieties/` のようなカスタムパスは 404 になる(URLルーティング競合)。
|
||||||
|
`/api/plans/crops/` を使うこと。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDF 出力
|
||||||
|
|
||||||
|
`GET /api/fertilizer/plans/{id}/pdf/`
|
||||||
|
|
||||||
|
- WeasyPrint を使用(reports アプリと同パターン)
|
||||||
|
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/pdf.html`
|
||||||
|
- フォーマット: A4横向き
|
||||||
|
- 内容: 圃場(行)× 肥料(列)のマトリクス表、行合計・列合計・総合計
|
||||||
|
- ファイル名: `fertilization_{year}_{plan_id}.pdf`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## フロントエンド画面
|
||||||
|
|
||||||
|
### 施肥計画一覧(`/fertilizer`)
|
||||||
|
|
||||||
|
- 年度セレクタ(localStorage `fertilizerYear` で保持)
|
||||||
|
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数
|
||||||
|
- 操作ボタン: PDF出力・編集・削除
|
||||||
|
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
|
||||||
|
|
||||||
|
### 肥料マスタ(`/fertilizer/masters`)
|
||||||
|
|
||||||
|
- 肥料一覧テーブル(名前・メーカー・容量・窒素・リン酸・カリ・備考)
|
||||||
|
- インライン行編集(EditRow コンポーネント)
|
||||||
|
- 新規追加フォーム
|
||||||
|
- 削除確認ダイアログ
|
||||||
|
|
||||||
|
### 施肥計画編集(`/fertilizer/new` / `/fertilizer/[id]/edit`)
|
||||||
|
|
||||||
|
`FertilizerEditPage.tsx`(`fertilizer/_components/`)を共有コンポーネントとして使用。
|
||||||
|
|
||||||
|
#### 操作フロー
|
||||||
|
|
||||||
|
1. **計画基本情報入力**: 計画名・年度・品種(ドロップダウン)
|
||||||
|
2. **圃場選択**: 品種選択後に候補圃場が自動取得(`candidate_fields` API)。チップ形式で追加/解除。候補外の圃場は「全圃場から追加」で手動選択
|
||||||
|
3. **肥料追加**: 「+肥料を追加」で肥料マスタからドロップダウン選択
|
||||||
|
4. **自動計算**: 各肥料に方式(per_tan/even/nitrogen)とパラメータを設定し「計算」ボタンでマトリクスに反映(上書き確認あり)
|
||||||
|
5. **四捨五入**: 肥料列ヘッダーの `≈` ボタン(青)を押すと袋数を整数に丸める。押した後は `↩` ボタン(琥珀色)に変わり、押すと元の計算値に戻る
|
||||||
|
6. **手動調整**: マトリクス表のセルを直接編集
|
||||||
|
7. **保存**: 「保存」ボタンで entries を一括送信
|
||||||
|
|
||||||
|
#### マトリクスの表示仕様
|
||||||
|
|
||||||
|
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
|
||||||
|
- `≈` ボタン押下後: セルの入力値が整数に丸められ、元の計算値が薄いグレーで参照表示される
|
||||||
|
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
|
||||||
|
- 編集中に計算を再実行すると、その肥料列の `adjusted` と `roundedColumns` がリセットされる
|
||||||
|
|
||||||
|
#### State 構成
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 基本情報
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [year, setYear] = useState(currentYear)
|
||||||
|
const [varietyId, setVarietyId] = useState<number | ''>('')
|
||||||
|
|
||||||
|
// 圃場・肥料
|
||||||
|
const [selectedFields, setSelectedFields] = useState<Field[]>([])
|
||||||
|
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([])
|
||||||
|
|
||||||
|
// 自動計算設定(肥料ごと)
|
||||||
|
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([])
|
||||||
|
// CalcSetting: { fertilizer_id, method: 'per_tan'|'even'|'nitrogen', param: string }
|
||||||
|
|
||||||
|
// マトリクス 2層構成(fieldId → fertilizerId → 袋数文字列)
|
||||||
|
const [calcMatrix, setCalcMatrix] = useState<Matrix>({}) // 自動計算値(参照用・変更不可表示)
|
||||||
|
const [adjusted, setAdjusted] = useState<Matrix>({}) // ユーザー確定値(保存対象)
|
||||||
|
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set()) // ↩ トグル管理
|
||||||
|
|
||||||
|
// effectiveValue(fieldId, fertId) で保存値を決定:
|
||||||
|
// adjusted[field][fert] があればそれを優先、なければ calcMatrix[field][fert]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ファイル構成
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/apps/fertilizer/
|
||||||
|
├── __init__.py
|
||||||
|
├── admin.py # Django admin 登録
|
||||||
|
├── apps.py # FertilizerConfig
|
||||||
|
├── models.py # Fertilizer, FertilizationPlan, FertilizationEntry
|
||||||
|
├── serializers.py # FertilizerSerializer, FertilizationPlanSerializer/WriteSerializer
|
||||||
|
├── views.py # FertilizerViewSet, FertilizationPlanViewSet, CandidateFieldsView, CalculateView
|
||||||
|
├── urls.py # DefaultRouter + candidate_fields/ + calculate/
|
||||||
|
├── migrations/
|
||||||
|
│ └── 0001_initial.py
|
||||||
|
└── templates/
|
||||||
|
└── fertilizer/
|
||||||
|
└── pdf.html # WeasyPrint テンプレート(A4横向き)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/fertilizer/
|
||||||
|
├── page.tsx # 施肥計画一覧
|
||||||
|
├── new/
|
||||||
|
│ └── page.tsx # 新規作成(FertilizerEditPage をラップ)
|
||||||
|
├── [id]/
|
||||||
|
│ └── edit/
|
||||||
|
│ └── page.tsx # 編集(FertilizerEditPage をラップ)
|
||||||
|
├── masters/
|
||||||
|
│ └── page.tsx # 肥料マスタ管理
|
||||||
|
└── _components/
|
||||||
|
└── FertilizerEditPage.tsx # 新規/編集共通コンポーネント(複雑)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 変更されたファイル
|
||||||
|
|
||||||
|
| ファイル | 変更内容 |
|
||||||
|
|---|---|
|
||||||
|
| `backend/keinasystem/settings.py` | `INSTALLED_APPS` に `'apps.fertilizer'` を追加 |
|
||||||
|
| `backend/keinasystem/urls.py` | `path('api/fertilizer/', include('apps.fertilizer.urls'))` を追加 |
|
||||||
|
| `frontend/src/types/index.ts` | `Fertilizer`, `FertilizationEntry`, `FertilizationPlan` 型を追加 |
|
||||||
|
| `frontend/src/components/Navbar.tsx` | Sprout アイコン + 施肥計画メニューを追加 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 型定義(TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/types/index.ts
|
||||||
|
|
||||||
|
export interface Fertilizer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maker: string | null;
|
||||||
|
capacity_kg: string | null;
|
||||||
|
nitrogen_pct: string | null;
|
||||||
|
phosphorus_pct: string | null;
|
||||||
|
potassium_pct: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationEntry {
|
||||||
|
id: number;
|
||||||
|
field: number;
|
||||||
|
field_name: string;
|
||||||
|
field_area_tan: string;
|
||||||
|
fertilizer: number;
|
||||||
|
fertilizer_name: string;
|
||||||
|
bags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationPlan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
variety: number;
|
||||||
|
variety_name: string;
|
||||||
|
crop_name: string;
|
||||||
|
field_count: number;
|
||||||
|
fertilizer_count: number;
|
||||||
|
entries: FertilizationEntry[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意点・既知の問題
|
||||||
|
|
||||||
|
### URL ルーティング競合(解決済み)
|
||||||
|
|
||||||
|
plans アプリの `DefaultRouter(r'', PlanViewSet)` が `plans/get-crops-with-varieties/` を
|
||||||
|
`{pk}/` パターンとして解釈して 404 になる問題があった。
|
||||||
|
`/api/plans/crops/`(CropViewSet)を使うことで回避。
|
||||||
|
|
||||||
|
### nitrogen 計算の前提条件
|
||||||
|
|
||||||
|
反当チッソ成分量方式(nitrogen)は、指定した肥料に `capacity_kg` と `nitrogen_pct` が
|
||||||
|
両方登録されていないと 400 エラーになる。肥料マスタ登録時にユーザーへ案内が必要。
|
||||||
|
|
||||||
|
### 袋数の精度
|
||||||
|
|
||||||
|
袋数は `decimal(8,2)`(小数点以下2桁)。0.01 刻みで四捨五入。
|
||||||
|
自動計算も `Decimal.quantize(Decimal('0.01'))` で丸める。
|
||||||
|
|
||||||
|
### entries の更新方式
|
||||||
|
|
||||||
|
PUT 時は entries を全削除→再作成する「全置換」方式。
|
||||||
|
部分更新は非対応(PATCH でも entries がある場合は全置換)。
|
||||||
|
|
||||||
|
### Next.js ホットリロードが効かない問題(Windows + Docker)
|
||||||
|
|
||||||
|
Windows 環境では Docker ボリュームマウント経由のファイル変更が inotify で検知されず、
|
||||||
|
フロントエンドのホットリロードが動かない。
|
||||||
|
|
||||||
|
**対策**: `docker-compose.yml` の frontend 環境変数に `WATCHPACK_POLLING: "true"` を追加。
|
||||||
|
ポーリング方式に切り替えることでファイル変更を検知できるようになる。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 将来の拡張(スコープ外)
|
||||||
|
|
||||||
|
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
|
||||||
|
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
|
||||||
|
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)
|
||||||
5
frontend/src/app/fertilizer/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FertilizerEditPage from '../../_components/FertilizerEditPage';
|
||||||
|
|
||||||
|
export default function EditFertilizerPage({ params }: { params: { id: string } }) {
|
||||||
|
return <FertilizerEditPage planId={parseInt(params.id)} />;
|
||||||
|
}
|
||||||
696
frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
|
||||||
|
|
||||||
|
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
|
||||||
|
|
||||||
|
interface CalcSetting {
|
||||||
|
fertilizer_id: number;
|
||||||
|
method: CalcMethod;
|
||||||
|
param: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// field_id → fertilizer_id → bags (string)
|
||||||
|
type Matrix = Record<number, Record<number, string>>;
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<CalcMethod, string> = {
|
||||||
|
per_tan: '反当袋数',
|
||||||
|
even: '均等配分',
|
||||||
|
nitrogen: '反当チッソ',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_UNIT: Record<CalcMethod, string> = {
|
||||||
|
per_tan: '袋/反',
|
||||||
|
even: '袋(総数)',
|
||||||
|
nitrogen: 'kg/反 (N)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isNew = !planId;
|
||||||
|
|
||||||
|
// ─── ヘッダー情報
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [year, setYear] = useState(currentYear);
|
||||||
|
const [varietyId, setVarietyId] = useState<number | ''>('');
|
||||||
|
|
||||||
|
// ─── マスタデータ
|
||||||
|
const [crops, setCrops] = useState<Crop[]>([]);
|
||||||
|
const [allFertilizers, setAllFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
|
||||||
|
// ─── 圃場
|
||||||
|
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||||
|
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||||
|
const [showFieldPicker, setShowFieldPicker] = useState(false);
|
||||||
|
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
// ─── 肥料(計画に使う肥料)
|
||||||
|
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
|
||||||
|
const [showFertPicker, setShowFertPicker] = useState(false);
|
||||||
|
|
||||||
|
// ─── マトリクス
|
||||||
|
// calcMatrix: 自動計算の結果(参照用・変更不可の表示値)
|
||||||
|
// adjusted: ユーザーが最終確定した値(保存対象)
|
||||||
|
// roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用)
|
||||||
|
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||||||
|
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||||||
|
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// ─── 初期データ取得
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
|
||||||
|
api.get('/plans/crops/'),
|
||||||
|
api.get('/fertilizer/fertilizers/'),
|
||||||
|
api.get('/fields/?ordering=display_order,id'),
|
||||||
|
]);
|
||||||
|
setCrops(cropsRes.data);
|
||||||
|
setAllFertilizers(fertsRes.data);
|
||||||
|
setAllFields(fieldsRes.data);
|
||||||
|
|
||||||
|
if (!isNew && planId) {
|
||||||
|
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
||||||
|
const plan: FertilizationPlan = planRes.data;
|
||||||
|
setName(plan.name);
|
||||||
|
setYear(plan.year);
|
||||||
|
setVarietyId(plan.variety);
|
||||||
|
|
||||||
|
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||||
|
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||||
|
setPlanFertilizers(ferts);
|
||||||
|
setCalcSettings(ferts.map((f: Fertilizer) => ({ fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' })));
|
||||||
|
|
||||||
|
const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field)));
|
||||||
|
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
|
||||||
|
setSelectedFields(fields);
|
||||||
|
|
||||||
|
// 保存済みの値は adjusted に復元(calc値はなし)
|
||||||
|
const newAdjusted: Matrix = {};
|
||||||
|
plan.entries.forEach((e) => {
|
||||||
|
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
|
||||||
|
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
||||||
|
});
|
||||||
|
setAdjusted(newAdjusted);
|
||||||
|
}
|
||||||
|
} 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 ?? '')}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [planId, isNew]);
|
||||||
|
|
||||||
|
// ─── 品種変更時: 候補圃場を取得して selectedFields をリセット
|
||||||
|
const fetchCandidates = useCallback(async (y: number, vId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`);
|
||||||
|
const candidates: Field[] = res.data;
|
||||||
|
setCandidateFields(candidates);
|
||||||
|
if (isNew) setSelectedFields(candidates);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, [isNew]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (varietyId && year) {
|
||||||
|
fetchCandidates(year, varietyId as number);
|
||||||
|
}
|
||||||
|
}, [varietyId, year, fetchCandidates]);
|
||||||
|
|
||||||
|
// ─── 肥料追加・削除
|
||||||
|
const addFertilizer = (fert: Fertilizer) => {
|
||||||
|
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||||
|
setPlanFertilizers((prev) => [...prev, fert]);
|
||||||
|
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||||
|
setShowFertPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFertilizer = (id: number) => {
|
||||||
|
if (!confirm('この肥料を計画から削除しますか?')) return;
|
||||||
|
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||||
|
const dropCol = (m: Matrix): Matrix => {
|
||||||
|
const next = { ...m };
|
||||||
|
Object.keys(next).forEach((fid) => {
|
||||||
|
const row = { ...next[Number(fid)] };
|
||||||
|
delete row[id];
|
||||||
|
next[Number(fid)] = row;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
setCalcMatrix(dropCol);
|
||||||
|
setAdjusted(dropCol);
|
||||||
|
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(id); return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 圃場追加・削除
|
||||||
|
const addField = (field: Field) => {
|
||||||
|
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||||
|
setSelectedFields((prev) => [...prev, field]);
|
||||||
|
setShowFieldPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (id: number) => {
|
||||||
|
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; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 自動計算
|
||||||
|
const runCalc = async (setting: CalcSetting) => {
|
||||||
|
if (!setting.param) return alert('パラメータを入力してください');
|
||||||
|
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/fertilizer/calculate/', {
|
||||||
|
method: setting.method,
|
||||||
|
param: parseFloat(setting.param),
|
||||||
|
fertilizer_id: setting.fertilizer_id,
|
||||||
|
field_ids: selectedFields.map((f) => f.id),
|
||||||
|
});
|
||||||
|
const results: { field_id: number; bags: number }[] = res.data;
|
||||||
|
|
||||||
|
// calc値を更新
|
||||||
|
setCalcMatrix((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
results.forEach(({ field_id, bags }) => {
|
||||||
|
if (!next[field_id]) next[field_id] = {};
|
||||||
|
next[field_id][setting.fertilizer_id] = String(bags);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// adjusted と丸め状態をリセット(新しい計算結果を再丸めさせる)
|
||||||
|
setAdjusted((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
results.forEach(({ field_id }) => {
|
||||||
|
if (next[field_id]) {
|
||||||
|
const row = { ...next[field_id] };
|
||||||
|
delete row[setting.fertilizer_id];
|
||||||
|
next[field_id] = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(setting.fertilizer_id); return next; });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
alert(err.response?.data?.error ?? '計算に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCalcSetting = (fertId: number, key: keyof CalcSetting, value: string) => {
|
||||||
|
setCalcSettings((prev) =>
|
||||||
|
prev.map((s) => (s.fertilizer_id === fertId ? { ...s, [key]: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── セル更新(adjusted を更新)
|
||||||
|
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||||
|
setAdjusted((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (!next[fieldId]) next[fieldId] = {};
|
||||||
|
next[fieldId][fertId] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
||||||
|
const roundColumn = (fertId: number) => {
|
||||||
|
if (roundedColumns.has(fertId)) {
|
||||||
|
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
||||||
|
setAdjusted((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
selectedFields.forEach((field) => {
|
||||||
|
if (next[field.id]) {
|
||||||
|
const row = { ...next[field.id] };
|
||||||
|
delete row[fertId];
|
||||||
|
next[field.id] = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(fertId); return next; });
|
||||||
|
} else {
|
||||||
|
// 四捨五入: calc値を整数に丸めて adjusted に書き込む
|
||||||
|
setAdjusted((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
selectedFields.forEach((field) => {
|
||||||
|
const calc = calcMatrix[field.id]?.[fertId];
|
||||||
|
if (calc !== undefined && calc !== '') {
|
||||||
|
const v = parseFloat(calc);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
if (!next[field.id]) next[field.id] = {};
|
||||||
|
next[field.id][fertId] = String(Math.round(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setRoundedColumns((prev) => { const next = new Set(prev); next.add(fertId); return next; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 集計(adjusted 優先、なければ calc 値)
|
||||||
|
const effectiveValue = (fieldId: number, fertId: number): number => {
|
||||||
|
const adj = adjusted[fieldId]?.[fertId];
|
||||||
|
const calc = calcMatrix[fieldId]?.[fertId];
|
||||||
|
const raw = adj !== undefined && adj !== '' ? adj : calc;
|
||||||
|
const v = parseFloat(raw ?? '0');
|
||||||
|
return isNaN(v) ? 0 : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowTotal = (fieldId: number) =>
|
||||||
|
planFertilizers.reduce((sum, f) => sum + effectiveValue(fieldId, f.id), 0);
|
||||||
|
|
||||||
|
const colTotal = (fertId: number) =>
|
||||||
|
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
|
||||||
|
|
||||||
|
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
|
||||||
|
|
||||||
|
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return alert('計画名を入力してください');
|
||||||
|
if (!varietyId) return alert('品種を選択してください');
|
||||||
|
if (selectedFields.length === 0) return alert('圃場を1つ以上選択してください');
|
||||||
|
|
||||||
|
const entries: { field_id: number; fertilizer_id: number; bags: number }[] = [];
|
||||||
|
selectedFields.forEach((field) => {
|
||||||
|
planFertilizers.forEach((fert) => {
|
||||||
|
const adj = adjusted[field.id]?.[fert.id];
|
||||||
|
const calc = calcMatrix[field.id]?.[fert.id];
|
||||||
|
const raw = adj !== undefined && adj !== '' ? adj : calc;
|
||||||
|
if (raw) {
|
||||||
|
const v = parseFloat(raw);
|
||||||
|
if (v > 0) entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: v });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = { name, year, variety: varietyId, entries };
|
||||||
|
if (isNew) {
|
||||||
|
await api.post('/fertilizer/plans/', payload);
|
||||||
|
} else {
|
||||||
|
await api.put(`/fertilizer/plans/${planId}/`, payload);
|
||||||
|
}
|
||||||
|
router.push('/fertilizer');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: unknown } };
|
||||||
|
console.error(err);
|
||||||
|
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PDF出力
|
||||||
|
const handlePdf = async () => {
|
||||||
|
if (!planId) return;
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `施肥計画_${year}_${name}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
alert('PDF出力に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||||
|
const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id));
|
||||||
|
const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">読み込み中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
{/* ヘッダー */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">
|
||||||
|
{isNew ? '施肥計画 新規作成' : '施肥計画 編集'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
onClick={handlePdf}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
PDF出力
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">計画名</label>
|
||||||
|
<input
|
||||||
|
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={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">年度</label>
|
||||||
|
<select
|
||||||
|
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))}
|
||||||
|
>
|
||||||
|
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-48">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">品種</label>
|
||||||
|
<select
|
||||||
|
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) : '')}
|
||||||
|
>
|
||||||
|
<option value="">品種を選択</option>
|
||||||
|
{crops.map((crop) => (
|
||||||
|
<optgroup key={crop.id} label={crop.name}>
|
||||||
|
{crop.varieties.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 対象圃場 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">
|
||||||
|
対象圃場
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">
|
||||||
|
{selectedFields.length}筆 /
|
||||||
|
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反
|
||||||
|
</span>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />圃場を追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedFields.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
品種を選択すると作付け計画から圃場が自動抽出されます
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedFields.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.id}
|
||||||
|
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">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自動計算設定パネル */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">自動計算設定</h2>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />肥料を追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{planFertilizers.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">肥料を追加してください</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{planFertilizers.map((fert) => {
|
||||||
|
const setting = calcSettings.find((s) => s.fertilizer_id === fert.id);
|
||||||
|
if (!setting) return null;
|
||||||
|
return (
|
||||||
|
<div key={fert.id} className="flex items-center gap-3 py-2 border-b last:border-b-0">
|
||||||
|
<span className="font-medium text-sm w-40 truncate" title={fert.name}>
|
||||||
|
{fert.name}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
|
value={setting.method}
|
||||||
|
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-xs w-24 text-right"
|
||||||
|
value={setting.param}
|
||||||
|
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||||
|
placeholder="値"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Calculator className="h-3 w-3" />計算
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFertilizer(fert.id)}
|
||||||
|
className="ml-auto text-gray-300 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* マトリクス表 */}
|
||||||
|
{selectedFields.length > 0 && planFertilizers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">圃場名</th>
|
||||||
|
<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);
|
||||||
|
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}
|
||||||
|
<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)}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||||
|
>
|
||||||
|
{isRounded ? '↩' : '≈'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">合計袋数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedFields.map((field) => (
|
||||||
|
<tr key={field.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td>
|
||||||
|
{planFertilizers.map((fert) => {
|
||||||
|
const calcVal = calcMatrix[field.id]?.[fert.id];
|
||||||
|
const adjVal = adjusted[field.id]?.[fert.id];
|
||||||
|
// adjusted が設定されているときだけ灰色参照を表示(丸め後)
|
||||||
|
const showRef = adjVal !== undefined && calcVal !== undefined;
|
||||||
|
// 入力欄: adjusted → calc値 → 空
|
||||||
|
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
|
||||||
|
return (
|
||||||
|
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{showRef && (
|
||||||
|
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||||
|
placeholder="-"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right font-medium">
|
||||||
|
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50 font-semibold">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-2 border border-gray-200">合計</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-gray-500">
|
||||||
|
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
{planFertilizers.map((f) => (
|
||||||
|
<td key={f.id} className="px-3 py-2 border border-gray-200 text-right">
|
||||||
|
{colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-green-700">
|
||||||
|
{grandTotal > 0 ? grandTotal.toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 圃場選択ピッカー */}
|
||||||
|
{showFieldPicker && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="font-bold">圃場を追加</h3>
|
||||||
|
<button onClick={() => setShowFieldPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-2">
|
||||||
|
{candidateFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-gray-500 px-2 py-1">作付け計画から({year}年度 / 選択品種)</p>
|
||||||
|
{candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<hr className="my-2" />
|
||||||
|
<p className="text-xs text-gray-500 px-2 py-1">その他の圃場</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 肥料選択ピッカー */}
|
||||||
|
{showFertPicker && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="font-bold">肥料を追加</h3>
|
||||||
|
<button onClick={() => setShowFertPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-2">
|
||||||
|
{availableFerts.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 px-3 py-4">追加できる肥料がありません</p>
|
||||||
|
) : (
|
||||||
|
availableFerts.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => addFertilizer(f)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{f.name}</span>
|
||||||
|
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||||||
|
{f.nitrogen_pct && (
|
||||||
|
<span className="ml-2 text-blue-500 text-xs">N:{f.nitrogen_pct}%</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div className="border-t mt-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowFertPicker(false); router.push('/fertilizer/masters'); }}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs text-green-600 hover:bg-green-50 rounded"
|
||||||
|
>
|
||||||
|
+ 新しい肥料をマスタに登録する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
frontend/src/app/fertilizer/masters/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Plus, Pencil, Trash2, ChevronLeft, Check, X } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Fertilizer } from '@/types';
|
||||||
|
|
||||||
|
const emptyForm = (): Omit<Fertilizer, 'id'> => ({
|
||||||
|
name: '',
|
||||||
|
maker: null,
|
||||||
|
capacity_kg: null,
|
||||||
|
nitrogen_pct: null,
|
||||||
|
phosphorus_pct: null,
|
||||||
|
potassium_pct: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FertilizerMastersPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||||
|
const [form, setForm] = useState(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFertilizers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFertilizers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/fertilizer/fertilizers/');
|
||||||
|
setFertilizers(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNew = () => {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setEditingId('new');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (f: Fertilizer) => {
|
||||||
|
setForm({
|
||||||
|
name: f.name,
|
||||||
|
maker: f.maker,
|
||||||
|
capacity_kg: f.capacity_kg,
|
||||||
|
nitrogen_pct: f.nitrogen_pct,
|
||||||
|
phosphorus_pct: f.phosphorus_pct,
|
||||||
|
potassium_pct: f.potassium_pct,
|
||||||
|
notes: f.notes,
|
||||||
|
});
|
||||||
|
setEditingId(f.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) return alert('肥料名を入力してください');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
maker: form.maker || null,
|
||||||
|
capacity_kg: form.capacity_kg || null,
|
||||||
|
nitrogen_pct: form.nitrogen_pct || null,
|
||||||
|
phosphorus_pct: form.phosphorus_pct || null,
|
||||||
|
potassium_pct: form.potassium_pct || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (editingId === 'new') {
|
||||||
|
await api.post('/fertilizer/fertilizers/', payload);
|
||||||
|
} else {
|
||||||
|
await api.put(`/fertilizer/fertilizers/${editingId}/`, payload);
|
||||||
|
}
|
||||||
|
await fetchFertilizers();
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: unknown } };
|
||||||
|
console.error(err);
|
||||||
|
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/fertilizer/fertilizers/${id}/`);
|
||||||
|
await fetchFertilizers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('削除に失敗しました(施肥計画で使用中の肥料は削除できません)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setField = (key: keyof typeof form, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value || null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer')}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">肥料マスタ</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={startNew}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b">
|
||||||
|
<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-right px-4 py-3 font-medium text-gray-700">1袋(kg)</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="text-right 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="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{editingId === 'new' && (
|
||||||
|
<EditRow
|
||||||
|
form={form}
|
||||||
|
setField={setField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{fertilizers.map((f) =>
|
||||||
|
editingId === f.id ? (
|
||||||
|
<EditRow
|
||||||
|
key={f.id}
|
||||||
|
form={form}
|
||||||
|
setField={setField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<tr key={f.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{f.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{f.maker ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.capacity_kg ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.nitrogen_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.phosphorus_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.potassium_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">{f.notes ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(f)}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(f.id, f.name)}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="text-gray-400 hover:text-red-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{fertilizers.length === 0 && editingId === null && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
肥料が登録されていません
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRow({
|
||||||
|
form,
|
||||||
|
setField,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
form: Omit<Fertilizer, 'id'>;
|
||||||
|
setField: (key: keyof Omit<Fertilizer, 'id'>, value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const inputCls = 'w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500';
|
||||||
|
const numCls = inputCls + ' text-right';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="bg-green-50">
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setField('name', e.target.value)}
|
||||||
|
placeholder="肥料名(必須)"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.maker ?? ''}
|
||||||
|
onChange={(e) => setField('maker', e.target.value)}
|
||||||
|
placeholder="メーカー"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={form.capacity_kg ?? ''}
|
||||||
|
onChange={(e) => setField('capacity_kg', e.target.value)}
|
||||||
|
placeholder="kg"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.nitrogen_pct ?? ''}
|
||||||
|
onChange={(e) => setField('nitrogen_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.phosphorus_pct ?? ''}
|
||||||
|
onChange={(e) => setField('phosphorus_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.potassium_pct ?? ''}
|
||||||
|
onChange={(e) => setField('potassium_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.notes ?? ''}
|
||||||
|
onChange={(e) => setField('notes', e.target.value)}
|
||||||
|
placeholder="備考"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-green-600 hover:text-green-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/app/fertilizer/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FertilizerEditPage from '../_components/FertilizerEditPage';
|
||||||
|
|
||||||
|
export default function NewFertilizerPage() {
|
||||||
|
return <FertilizerEditPage />;
|
||||||
|
}
|
||||||
177
frontend/src/app/fertilizer/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { FertilizationPlan } from '@/types';
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
export default function FertilizerPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [year, setYear] = useState<number>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('fertilizerYear');
|
||||||
|
if (saved) return parseInt(saved);
|
||||||
|
}
|
||||||
|
return currentYear;
|
||||||
|
});
|
||||||
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('fertilizerYear', String(year));
|
||||||
|
fetchPlans();
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
||||||
|
setPlans(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/fertilizer/plans/${id}/`);
|
||||||
|
await fetchPlans();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('削除に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdf = async (id: number, name: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `施肥計画_${year}_${name}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('PDF出力に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Sprout className="h-6 w-6 text-green-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/masters')}
|
||||||
|
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
肥料マスタ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規作成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 年度セレクタ */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}年度</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
|
) : plans.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
|
||||||
|
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>{year}年度の施肥計画はありません</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
|
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
||||||
|
>
|
||||||
|
最初の計画を作成する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b">
|
||||||
|
<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-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>
|
||||||
|
</tr>
|
||||||
|
</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>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{plan.crop_name} / {plan.variety_name}
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePdf(plan.id, plan.name)}
|
||||||
|
className="text-gray-400 hover:text-blue-600"
|
||||||
|
title="PDF出力"
|
||||||
|
>
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
||||||
|
className="text-gray-400 hover:text-green-600"
|
||||||
|
title="編集"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(plan.id, plan.name)}
|
||||||
|
className="text-gray-400 hover:text-red-600"
|
||||||
|
title="削除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud } from 'lucide-react';
|
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -111,6 +111,17 @@ export default function Navbar() {
|
|||||||
<Cloud className="h-4 w-4 mr-2" />
|
<Cloud className="h-4 w-4 mr-2" />
|
||||||
気象
|
気象
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/fertilizer')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sprout className="h-4 w-4 mr-2" />
|
||||||
|
施肥計画
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|||||||
@@ -56,6 +56,41 @@ export interface Plan {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Fertilizer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maker: string | null;
|
||||||
|
capacity_kg: string | null;
|
||||||
|
nitrogen_pct: string | null;
|
||||||
|
phosphorus_pct: string | null;
|
||||||
|
potassium_pct: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationEntry {
|
||||||
|
id?: number;
|
||||||
|
field: number;
|
||||||
|
field_name?: string;
|
||||||
|
field_area_tan?: string;
|
||||||
|
fertilizer: number;
|
||||||
|
fertilizer_name?: string;
|
||||||
|
bags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationPlan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
variety: number;
|
||||||
|
variety_name: string;
|
||||||
|
crop_name: string;
|
||||||
|
entries: FertilizationEntry[];
|
||||||
|
field_count: number;
|
||||||
|
fertilizer_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MailSender {
|
export interface MailSender {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'address' | 'domain';
|
type: 'address' | 'domain';
|
||||||
|
|||||||
BIN
testing/recordings/correspondence_api_1772337223924.webp
Normal file
|
After Width: | Height: | Size: 9.6 MiB |
BIN
testing/recordings/dashboard_test_1772336335244.webp
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
testing/recordings/fertilizer_test_1772336994610.webp
Normal file
|
After Width: | Height: | Size: 785 KiB |
BIN
testing/recordings/field_detail_test_1772336588494.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
testing/recordings/login_flow_1772336274097.webp
Normal file
|
After Width: | Height: | Size: 768 KiB |
BIN
testing/recordings/reports_allocation_1772336843870.webp
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
testing/recordings/rules_reports_test_1772337104257.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
testing/recordings/weather_page_test_1772336922550.webp
Normal file
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 66 KiB |
BIN
testing/screenshots/02_fields/field_detail_top_1772336614836.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 132 KiB |
BIN
testing/screenshots/02_fields/fields_list_page_1772336436720.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 61 KiB |
BIN
testing/screenshots/08_mail/mail_history_page_1772337055980.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
testing/screenshots/08_mail/mail_rules_page_1772337122287.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 48 KiB |
BIN
testing/subagent_generated/e2e/screenshots/01-initial.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 48 KiB |
BIN
testing/subagent_generated/e2e/screenshots/02-after-login.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
testing/subagent_generated/e2e/screenshots/03-404.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
testing/subagent_generated/e2e/screenshots/03-current-state.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 112 KiB |
BIN
testing/subagent_generated/e2e/screenshots/05-matrix-table.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 112 KiB |
BIN
testing/subagent_generated/e2e/screenshots/B-table-header.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
testing/subagent_generated/e2e/screenshots/D-fertilizer-cell.png
Normal file
|
After Width: | Height: | Size: 283 B |
BIN
testing/subagent_generated/e2e/screenshots/f01-initial.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f03-picker-open.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 119 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f04-no-table.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f05-before-calc.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f06-after-calc.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f09-final-full.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
testing/subagent_generated/e2e/screenshots/f09-final.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
BIN
testing/subagent_generated/e2e/screenshots/step1_initial.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
testing/subagent_generated/e2e/screenshots/step3_fert_picker.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
testing/subagent_generated/e2e/screenshots/step4_with_matrix.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 41 KiB |
35
testing/subagent_generated/playwright_debug.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
mkdirSync('C:/tmp/playwright_screenshots', { recursive: true });
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
await page.goto('http://localhost:3000/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'C:/tmp/playwright_screenshots/login_page.png', fullPage: true });
|
||||||
|
|
||||||
|
// Debug HTML form
|
||||||
|
const formHTML = await page.locator('form').innerHTML().catch(() => 'no form found');
|
||||||
|
console.log('Form HTML:', formHTML.substring(0, 1000));
|
||||||
|
|
||||||
|
const allInputs = await page.locator('input').all();
|
||||||
|
console.log('Inputs count:', allInputs.length);
|
||||||
|
for (const input of allInputs) {
|
||||||
|
const name = await input.getAttribute('name');
|
||||||
|
const type = await input.getAttribute('type');
|
||||||
|
const placeholder = await input.getAttribute('placeholder');
|
||||||
|
console.log(`Input: name=${name}, type=${type}, placeholder=${placeholder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allButtons = await page.locator('button').all();
|
||||||
|
for (const btn of allButtons) {
|
||||||
|
const text = await btn.textContent();
|
||||||
|
const type = await btn.getAttribute('type');
|
||||||
|
console.log(`Button: text="${text?.trim()}", type=${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
58
testing/subagent_generated/playwright_full_test.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
const screenshotDir = 'C:/tmp/playwright_screenshots';
|
||||||
|
mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
async function screenshot(name) {
|
||||||
|
const path = `${screenshotDir}/${name}.png`;
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
console.log(` [Screenshot saved: ${name}.png]`);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Step 1: Navigate to /fertilizer/new ===');
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
console.log('URL:', page.url());
|
||||||
|
|
||||||
|
if (page.url().includes('/login')) {
|
||||||
|
console.log('\n=== Step 2: Login ===');
|
||||||
|
// Use the id-based selectors since name attribute is null
|
||||||
|
await page.fill('#username', 'akira');
|
||||||
|
await page.fill('input[type="password"]', 'keina2025');
|
||||||
|
await screenshot('login_filled');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForNavigation({ timeout: 10000 }).catch(() => console.log('No navigation event'));
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
console.log('After login URL:', page.url());
|
||||||
|
|
||||||
|
// Navigate to fertilizer/new
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
console.log('After redirect URL:', page.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
await screenshot('step3_fertilizer_new');
|
||||||
|
console.log('\n=== Step 3: Page content ===');
|
||||||
|
const h1Text = await page.locator('h1, h2').first().textContent().catch(() => 'not found');
|
||||||
|
console.log('Heading:', h1Text);
|
||||||
|
|
||||||
|
// List all select elements
|
||||||
|
const selects = await page.locator('select').all();
|
||||||
|
console.log('Select elements:', selects.length);
|
||||||
|
for (const sel of selects) {
|
||||||
|
const label = await sel.getAttribute('aria-label') || await sel.getAttribute('id') || 'no label';
|
||||||
|
const options = await sel.locator('option').allTextContents();
|
||||||
|
console.log(` Select [${label}]: options = ${options.slice(0, 10).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all visible text to understand the page
|
||||||
|
const allText = await page.locator('body').textContent();
|
||||||
|
console.log('Page text (first 1000 chars):', allText?.substring(0, 1000));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
76
testing/subagent_generated/playwright_full_test2.mjs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
const screenshotDir = 'C:/tmp/playwright_screenshots';
|
||||||
|
mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
async function screenshot(name) {
|
||||||
|
const path = `${screenshotDir}/${name}.png`;
|
||||||
|
await page.screenshot({ path, fullPage: false });
|
||||||
|
console.log(` [Screenshot: ${name}.png]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
if (page.url().includes('/login')) {
|
||||||
|
await page.fill('#username', 'akira');
|
||||||
|
await page.fill('input[type="password"]', 'keina2025');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForNavigation({ timeout: 10000 }).catch(() => {});
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Select にこまる
|
||||||
|
console.log('\n=== Step 3: Select 品種 "にこまる" ===');
|
||||||
|
const selects = await page.locator('select').all();
|
||||||
|
await selects[1].selectOption({ label: 'にこまる' });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await screenshot('step3_nikkomaru_selected');
|
||||||
|
console.log('Selected にこまる - 15 fields auto-added');
|
||||||
|
|
||||||
|
// Step 4: Click + 肥料を追加 button
|
||||||
|
console.log('\n=== Step 4: Click "+ 肥料を追加" ===');
|
||||||
|
const addBtn = page.locator('button').filter({ hasText: '肥料を追加' }).first();
|
||||||
|
await addBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Modal appeared - click グアノ
|
||||||
|
console.log(' Modal open - clicking グアノ...');
|
||||||
|
const guanoItem = page.locator('text=グアノ').first();
|
||||||
|
await guanoItem.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await screenshot('step4_guano_added');
|
||||||
|
|
||||||
|
// Check what appeared on the page
|
||||||
|
const pageText = await page.locator('body').textContent();
|
||||||
|
const cleanText = pageText?.replace(/\s+/g, ' ');
|
||||||
|
const relevantPart = cleanText?.match(/自動計算設定.{0,500}/)?.[0] || cleanText?.substring(0, 600);
|
||||||
|
console.log('After グアノ added:', relevantPart);
|
||||||
|
|
||||||
|
// Find the param input for グアノ
|
||||||
|
console.log('\n=== Checking fertilizer section structure ===');
|
||||||
|
const fertSection = page.locator('[class*="fertilizer"], [class*="Fertilizer"]').first();
|
||||||
|
const fertHTML = await page.locator('body').innerHTML();
|
||||||
|
// Look for the input near グアノ
|
||||||
|
const inputs = await page.locator('input[type="number"], input[type="text"]').all();
|
||||||
|
console.log('Number of inputs:', inputs.length);
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
const val = await inputs[i].inputValue();
|
||||||
|
const placeholder = await inputs[i].getAttribute('placeholder');
|
||||||
|
const type = await inputs[i].getAttribute('type');
|
||||||
|
console.log(` Input[${i}]: value="${val}", placeholder="${placeholder}", type="${type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for 計算 button
|
||||||
|
const calcButton = page.locator('button').filter({ hasText: '計算' });
|
||||||
|
const calcVisible = await calcButton.isVisible();
|
||||||
|
console.log('計算 button visible:', calcVisible);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
89
testing/subagent_generated/playwright_full_test3.mjs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
const screenshotDir = 'C:/tmp/playwright_screenshots';
|
||||||
|
mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
async function screenshot(name) {
|
||||||
|
const path = `${screenshotDir}/${name}.png`;
|
||||||
|
await page.screenshot({ path, fullPage: false });
|
||||||
|
console.log(` [Screenshot: ${name}.png]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
if (page.url().includes('/login')) {
|
||||||
|
await page.fill('#username', 'akira');
|
||||||
|
await page.fill('input[type="password"]', 'keina2025');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForNavigation({ timeout: 10000 }).catch(() => {});
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.goto('http://localhost:3000/fertilizer/new');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select にこまる
|
||||||
|
const selects = await page.locator('select').all();
|
||||||
|
await selects[1].selectOption({ label: 'にこまる' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Click + 肥料を追加
|
||||||
|
const addBtn = page.locator('button').filter({ hasText: '肥料を追加' }).first();
|
||||||
|
await addBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click グアノ in modal
|
||||||
|
await page.locator('text=グアノ').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Step 5: Enter "3" in the param field and click 計算
|
||||||
|
console.log('\n=== Step 5: Enter "3" in param field and click 計算 ===');
|
||||||
|
|
||||||
|
// The first input with placeholder "値" is the param field
|
||||||
|
const paramInput = page.locator('input[placeholder="値"]');
|
||||||
|
const paramVisible = await paramInput.isVisible();
|
||||||
|
console.log('Param input visible:', paramVisible);
|
||||||
|
|
||||||
|
await paramInput.fill('3');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const calcBtn = page.locator('button').filter({ hasText: '計算' });
|
||||||
|
await calcBtn.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await screenshot('step5_after_calc');
|
||||||
|
|
||||||
|
// Step 6: Check matrix cells - do they show decimal values in input fields?
|
||||||
|
console.log('\n=== Step 6: Check matrix cell values BEFORE clicking ≈ ===');
|
||||||
|
const allInputs = await page.locator('input[type="number"]').all();
|
||||||
|
console.log('Number of number inputs:', allInputs.length);
|
||||||
|
for (let i = 0; i < Math.min(allInputs.length, 20); i++) {
|
||||||
|
const val = await allInputs[i].inputValue();
|
||||||
|
const placeholder = await allInputs[i].getAttribute('placeholder');
|
||||||
|
console.log(` Input[${i}]: value="${val}", placeholder="${placeholder}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Find and examine the グアノ column header button (≈ button)
|
||||||
|
console.log('\n=== Step 7: Find ≈ button in グアノ column header ===');
|
||||||
|
// Look for buttons with ≈ or similar content
|
||||||
|
const allButtons = await page.locator('button').all();
|
||||||
|
console.log('Total buttons on page:', allButtons.length);
|
||||||
|
for (let i = 0; i < allButtons.length; i++) {
|
||||||
|
const text = await allButtons[i].textContent();
|
||||||
|
const cls = await allButtons[i].getAttribute('class');
|
||||||
|
if (text && (text.includes('≈') || text.includes('↩') || text.includes('~') || text.trim().length <= 3)) {
|
||||||
|
console.log(` Button[${i}]: text="${text?.trim()}", class="${cls?.substring(0, 100)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get page text in the グアノ column area
|
||||||
|
const bodyText = await page.locator('body').textContent();
|
||||||
|
const guanoSection = bodyText?.match(/グアノ.{0,300}/)?.[0];
|
||||||
|
console.log('グアノ section text:', guanoSection);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||