Compare commits

..

6 Commits

Author SHA1 Message Date
Akira
b855608084 試験結果 2026-03-01 13:42:13 +09:00
Akira
cfd67e0d55 施肥計画編集画面に四捨五入トグル機能を追加
- calcMatrix(計算値)+ adjusted(確定値)の2層構成に変更
- 肥料列ヘッダーに ≈(青)/ ↩(琥珀)トグルボタンを追加
- 四捨五入後は元の計算値をグレーで参照表示
- docker-compose.yml に WATCHPACK_POLLING=true を追加(Windowsホットリロード修正)
- マスタードキュメント(文書13)を新 UI 仕様に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:40:38 +09:00
Akira
8ac3a00737 施肥計画のマスタードキュメントを追加
document/13_マスタードキュメント_施肥計画編.md を新規作成
データモデル・全API仕様・自動計算ロジック・フロントエンド画面・ファイル構成・注意点を網羅

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:16:42 +09:00
Akira
f207f5de27 施肥計画機能を追加(年度×品種単位のマトリクス管理)
- Backend: apps/fertilizer を新規追加
  - Fertilizer(肥料マスタ)、FertilizationPlan、FertilizationEntry モデル
  - 肥料マスタ・施肥計画 CRUD API
  - 3方式の自動計算API(反当袋数・均等配分・反当チッソ成分量)
  - 作付け計画から圃場候補を取得する API
  - WeasyPrint による PDF 出力(圃場×肥料=袋数 マトリクス表)
- Frontend: app/fertilizer を新規追加
  - 施肥計画一覧(年度セレクタ・PDF出力・編集・削除)
  - 肥料マスタ管理(インライン編集)
  - 施肥計画編集(品種選択→圃場自動取得→肥料追加→自動計算→マトリクス手動調整)
- Navbar に「施肥計画」メニューを追加(Sprout アイコン)
- Cursor ルールファイル・連携ガイドを削除(Claude Code 単独運用へ)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:14:29 +09:00
Akira
371e40236c cursorとclaude codeの協調案11 2026-02-28 18:41:40 +09:00
Akira
6e99164e3f Triliumに記録しました。
ドキュメント整備が全て完了しました。今回のセッションで行った作業のまとめ:

実装した機能

Windmill フロー f/weather/weather_sync(毎朝6時 Asia/Tokyo)の作成・本番稼働
/weather フロントエンドページ(年別集計 / 期間指定の2モード)
Recharts 3.7.0 による月別・日次グラフ(気温折れ線、降水量棒+日照折れ線 2軸)
整備したドキュメント

ドキュメント	内容
CLAUDE.md	weather 画面追加・Windmill フロー・マスタードキュメントリンク
document/12_マスタードキュメント_気象データ編.md	気象機能の完全リファレンス(新規作成)
MEMORY.md	Windmill API パターン・Recharts TypeScript パターン
Trilium マスタードキュメント	フロントエンド2モード・Recharts・document/12_ 参照追加
2026-02-28 14:05:32 +09:00
106 changed files with 3429 additions and 7 deletions

View File

@@ -28,7 +28,10 @@
"Bash(docker compose exec:*)",
"Bash(docker-compose restart:*)",
"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)"
]
}
}

View File

@@ -56,6 +56,9 @@
```
keinasystem_t02/
├── CLAUDE.md # このファイルClaude向けガイド
├── .cursor/
│ └── rules/
│ └── 30_Cursorガイド.md # Cursor専用ガイド
├── document/ # 詳細設計書(人間向け)
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
│ ├── 01_プロダクトビジョン.md
@@ -93,6 +96,7 @@ keinasystem_t02/
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
│ ├── history/ # メール処理履歴
│ └── rules/ # 送信者ルール管理
├── weather/ # 気象データ画面(年別集計・期間指定・グラフ)
└── settings/
└── password/ # パスワード変更
```
@@ -167,6 +171,25 @@ WeatherRecord (日次気象記録)
└── pressure_min (最低気圧hPa)
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
Fertilizer (肥料マスタ)
├── name肥料名、必須・unique
├── makerメーカー、任意
├── capacity_kg1袋重量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/similarity/?year=` 類似年分析(月別パターン比較)
- 管理コマンド: `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モデルに追加
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts
- **将来計画**: 開花・収穫予測品種ごとの目標GDD設定 → 到達日予測)
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
10. **施肥計画機能**:
- Django `apps/fertilizer` アプリFertilizer, FertilizationPlan, FertilizationEntry
- APIJWT認証: `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
# ⚠️ --env-file .env.production を必ず付けること省略するとSECRET_KEYが空でbackendが起動しない
ssh keinafarm-claude 'cd /home/akira/keinasystem_t02 && \
docker compose -f docker-compose.prod.yml --env-file .env.production build && \
docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
# ⚠️ 本番ファイルは keinasystem ユーザー所有。git pull は sudo -u keinasystem で実行
ssh keinafarm-claude 'sudo -u keinasystem git -C /home/keinasystem/keinasystem_t02 pull origin main && \
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/11_マスタードキュメント_メール通知関連編.md`
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
### 設計ドキュメント(プロジェクト横断)
@@ -405,7 +439,9 @@ docker-compose exec backend python manage.py migrate
## 📝 更新履歴
- 2026-02-28: 気象データ基盤を実装。`apps/weather` Django appWeatherRecord, 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-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPIAPIキー認証、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加

View File

View 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]

View 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 = '施肥計画'

View 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')},
},
),
]

View 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}"

View 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'],
)

View 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>

View 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'),
]

View 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)

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'apps.reports',
'apps.mail',
'apps.weather',
'apps.fertilizer',
]
MIDDLEWARE = [

View File

@@ -57,4 +57,5 @@ urlpatterns = [
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
path('api/mail/', include('apps.mail.urls')),
path('api/weather/', include('apps.weather.urls')),
path('api/fertilizer/', include('apps.fertilizer.urls')),
]

View File

@@ -48,6 +48,7 @@ services:
container_name: keinasystem_frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
WATCHPACK_POLLING: "true"
ports:
- "3000:3000"
volumes:

View 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-KeyMAIL_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 で変換

View 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"` を追加。
ポーリング方式に切り替えることでファイル変更を検知できるようになる。
---
## 将来の拡張(スコープ外)
- **配置計画**: 複数圃場分を一か所にまとめる時の置き場所割り当て(別機能として検討)
- **購入管理**: 肥料の購入・在庫管理(施肥計画の集計から購入数量を自動算出)
- **作業記録との連携**: 施肥計画の実施記録(実施日・実際の袋数)

View File

@@ -0,0 +1,5 @@
import FertilizerEditPage from '../../_components/FertilizerEditPage';
export default function EditFertilizerPage({ params }: { params: { id: string } }) {
return <FertilizerEditPage planId={parseInt(params.id)} />;
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import FertilizerEditPage from '../_components/FertilizerEditPage';
export default function NewFertilizerPage() {
return <FertilizerEditPage />;
}

View 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>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
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';
export default function Navbar() {
@@ -111,6 +111,17 @@ export default function Navbar() {
<Cloud className="h-4 w-4 mr-2" />
</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 className="flex items-center space-x-1">

View File

@@ -56,6 +56,41 @@ export interface Plan {
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 {
id: number;
type: 'address' | 'domain';

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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();

View 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();

View 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();

View 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();

Some files were not shown because too many files have changed in this diff Show More