Compare commits

..

21 Commits

Author SHA1 Message Date
Akira
8de1ae70aa .codex .mcpを除外 2026-04-09 20:40:30 +09:00
akira
3901caf668 docs: reflect todo spec review feedback 2026-04-09 16:36:19 +09:00
akira
5f58c2c686 指摘を反映して 改善案/TODO管理機能仕様書案.md を更新しました。
主な修正は、Todo.year の追加、TodoCrop / TodoVariety の具体定義追加、priority の 1000 刻み再採番ルール明記、plan_links がある場合だけ対象圃場整合性を検証する形への明確化、levee_work を「MVP では計画リンクなしの work_type」として整理、plan_type + plan_id を Serializer で各 FK に変換する方針追記、admin.py と migrations の追加、見出し階層の修正です。

あわせて、実績連携フラグ名は should_link_record に寄せました。まだコミットはしていないので、必要ならこの修正分もそのままコミットします。
2026-04-09 16:34:30 +09:00
akira
83525c2f59 docs: add todo management spec draft 2026-04-09 16:27:42 +09:00
akira
627d7e4f59 docs: tighten pesticide ingredient limit consistency 2026-04-09 15:52:29 +09:00
akira
9059b2b51e 仕様書を更新しました。更新先は 農薬散布管理編.md です。
- 有効成分総使用回数の集計方式を COUNT(DISTINCT SprayEvent.id) に変更
  (農薬取締法上「1回の散布=1回の使用」の解釈に準拠、1イベント=1回で統一)
- PesticideIngredientLimit に「同一成分・同一作物であれば製品が異なっても上限値は同一」の注記を追加
- 設計判断 #5 を更新:有効成分も製品使用回数と同様に COUNT(DISTINCT SprayEvent.id) で集計する根拠を記載
- 設計判断の番号を整理(#7〜#10 → #8〜#11)
2026-04-09 15:44:44 +09:00
akira
7d2eb1ebe2 Findings
同一イベント内で同じ有効成分を含む複数製品を使った場合、総使用回数を過少計上します。
18_マスタードキュメント_農薬散布管理編.md:39 (line 39) では「同一有効成分を含む複数製品は合算カウント」と定義していますが、集計式は 同:251 (line 251) の COUNT(DISTINCT SprayEvent.id) です。これだと 1 回の散布で MEP剤A と MEP剤B を同時使用したケースが 2 回ではなく 1 回になります。1イベント=1回 は製品単位には合っても、有効成分の「複数製品合算」とは衝突しています。

SprayEventResolvedField を正源にしたはずなのに、設計判断がまだ旧仕様のままで矛盾しています。
集計の正源は 同:232 (line 232) で SprayEventResolvedField.crop_name_snapshot に統一されていますが、設計判断では 同:599 (line 599) に「作付け計画(Plan)と照合」と残っています。さらに 同:602 (line 602) では削除したはずの crop_snapshot / variety_snapshot をまだ保持対象として書いています。実装者がここを読むと旧設計に引っ張られます。

製品使用回数も、同一イベント内の重複明細をどう扱うかが未定義で、式とモデルが噛み合っていません。
集計式は 同:239 (line 239) の COUNT(DISTINCT SprayEvent.id) ですが、明細モデルには 同:213 (line 213) 以降で event + pesticide の一意制約がありません。つまり同じ農薬を同一イベントに 2 行入れられる設計なのに、集計では 1 回に潰れます。仕様として「同一イベント内で同一農薬は1回しか登録できない」を明記して一意制約を持たせるか、重複明細の意味を定義した方が安全です。

大筋ではかなり良くなっていて、特に「作物単位での法的管理」と「圃場ごとの正源を SprayEventResolvedField に寄せた」方向は明快でした。上の3点だけ揃えると、実装時の解釈ぶれがかなり減ります。
2026-04-09 15:16:45 +09:00
akira
3e2942b479 変更内容
集計ロジックの明確化(農薬取締法の要件を明示):

集計単位の説明に「農薬取締法上、使用回数は作物単位で管理する義務がある」を明記
グループ内に複数作物が混在する場合の動作を明示 → 同一イベントの農薬が水稲・大豆それぞれの回数に +1 カウントされる
集計の正源は SprayEventResolvedField.crop_name_snapshot(圃場ごと)に統一
不要フィールドの削除:

SprayEvent.crop_snapshot / variety_snapshot を削除(役割が SprayEventResolvedField に統合されたため)
APIレスポンス例からも除去
集計式の精緻化:

「1イベント = 1回」のカウント原則を明示(COUNT(DISTINCT SprayEvent.id))— グループ内に圃場が何筆あっても1散布作業は1回
2026-04-09 15:11:15 +09:00
akira
70fe3824b3 docs: refine pesticide management spec 2026-04-09 14:56:21 +09:00
akira
10f2b6f77f 農薬散布記録作成 2026-04-09 14:54:12 +09:00
akira
6dfcd0be06 テスト結果 2026-04-07 11:47:25 +09:00
akira
1371eef648 必須の指摘を修正しました。更新したのは Navbar.tsx です。
修正内容は2点です。

/fertilizer/masters を 施肥計画 のアクティブ判定から除外
運搬計画 のアイコンを FlaskConical から Truck に変更
確認は docker compose exec -T frontend npx tsc --noEmit で通っています。今回のレビューで必須だった重複アクティブはこれで解消しています
2026-04-07 11:19:05 +09:00
akira
ac0bc7b6a9 マスタードキュメント化しました。追加したのは 17_マスタードキュメント_ナビゲーション再編編.md です。
内容は、今回の仕様案をそのまま移すだけでなく、

なぜこの再編が必要になったか
なぜ マスター / 帳票・連携 という分類にしたか
URL とメニューをどう切り分けるか
Phase 1〜3 をどう進めるか
まで、長期参照向けに整理してあります。
改善案/ナビゲーション再編仕様書.md は実装向け詳細、今回追加したマスタードキュメントは長期保管用、Issue #13 は議論の背景、という役割分担にしています。必要なら次に、既存のどこかからこの 17_...md へ参照リンクを足します。
2026-04-07 11:12:19 +09:00
akira
1c474e9692 仕様書を更新しました。更新先は ナビゲーション再編仕様書.md です。
反映した内容は次の通りです。

5-3 に、初期実装ではブラウザ標準の Tab 移動を基本とすることを追記
5-3 に、矢印キーでの項目間移動は Phase 1 の必須要件外と明記
9 に、ドロップダウン展開後は Tab で各項目へ到達できることを追加
9 に、矢印キー移動は将来のアクセシビリティ強化項目として扱うと追記
これで、キーボード操作の範囲について実装者が迷いにくくなったはずです。
2026-04-07 11:09:51 +09:00
akira
0cd90e61db 仕様書を更新しました。変更先は ナビゲーション再編仕様書.md です。
今回入れた主な追記はこの3点です。

1-4 に Issue #13 との役割分担を追加
10-2 に NavGroup / NavItem の階層構造と match の位置づけを追加
10-3 に URL とナビゲーションの分離原則、Route Groups の扱い方を追加
これで、仕様書だけ読んでも実装方針が分かり、なぜそうしているかは Issue #13 にたどれる構成になりました。必要なら次に、10-2 のサンプル navGroups を今の分類に合わせてもっと具体化します。
2026-04-07 11:01:44 +09:00
akira
8de27de335 第2版 2026-04-07 10:33:29 +09:00
akira
71b8258281 メニューの整理案 2026-04-07 10:01:02 +09:00
Akira
4516a74772 Fix sync_db.sh to run migrate after DB restore
サーバーより新しいマイグレーションがローカルに存在する場合、
リストア後にmigrateを実行しないと500エラーになるバグを修正。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:32:23 +09:00
Akira
a42ccb5cda Add local production test environment setup
- docker-compose.local.yml: 本番Dockerfile使用・Traefikなし・ポート直接公開
- deploy_local.sh: ローカル環境のビルド・起動スクリプト
- sync_db.sh: サーバーDBダンプをローカルに取り込むスクリプト
- document/20_ローカルテスト環境.md: 手順ドキュメント

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:32:23 +09:00
akira
4a1db5ef27 #12 対応を入れました。
畔塗記録 API に total_area_tan を追加して、一覧の各記録に「圃場数 / 面積合計」が出るようにしました。あわせて、作成・編集フォームの「対象圃場一覧」にも、選択中の合計面積を表示しています。主な変更は serializers.py、tests.py、page.tsx、index.ts です。

確認できたこと:

docker compose -f docker-compose.develop.yml exec backend python manage.py test apps.levee_work OK
docker exec keinasystem_frontend npm run build OK
まだコミットはしていません。必要ならこのままコミットして push します。
2026-04-06 17:23:06 +09:00
akira
c90c6210e1 Add fertilization plan merge workflow 2026-04-06 16:49:44 +09:00
23 changed files with 3442 additions and 192 deletions

View File

@@ -74,12 +74,15 @@
"mcp__butler__inspect_runtime_config", "mcp__butler__inspect_runtime_config",
"mcp__butler__execute_task", "mcp__butler__execute_task",
"Bash(git -C /home/akira/develop/keinasystem remote -v)", "Bash(git -C /home/akira/develop/keinasystem remote -v)",
"Bash(cat butler/skills/read_from_gitea*)" "Bash(cat butler/skills/read_from_gitea*)",
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp", "C:\\Users\\akira\\AppData\\Local\\Temp",
"C:\\Users\\akira\\Develop\\keinasystem_t02", "C:\\Users\\akira\\Develop\\keinasystem_t02",
"/home/akira/develop" "/home/akira/develop",
"/home/akira/.docker",
"/tmp"
] ]
} }
} }

2
.gitignore vendored
View File

@@ -15,3 +15,5 @@ postgres_data/
nul nul
*.tsbuildinfo *.tsbuildinfo
.mcp.json
.codex

View File

@@ -108,6 +108,7 @@ ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` | | 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` | | 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` | | 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
| 農薬散布管理 | `document/18_マスタードキュメント_農薬散布管理編.md` |
| データモデル全体 | `document/03_データ仕様書.md` | | データモデル全体 | `document/03_データ仕様書.md` |
--- ---

View File

@@ -74,6 +74,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
spread_status = serializers.SerializerMethodField() spread_status = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True) is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True) confirmed_at = serializers.DateTimeField(read_only=True)
is_variety_change_plan = serializers.SerializerMethodField()
class Meta: class Meta:
model = FertilizationPlan model = FertilizationPlan
@@ -94,6 +95,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
'spread_status', 'spread_status',
'is_confirmed', 'is_confirmed',
'confirmed_at', 'confirmed_at',
'is_variety_change_plan',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@@ -134,6 +136,9 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
return 'partial' return 'partial'
return 'completed' return 'completed'
def get_is_variety_change_plan(self, obj):
return obj.name.endswith('(品種変更移動)')
class FertilizationPlanWriteSerializer(serializers.ModelSerializer): class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False) entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)

View File

@@ -3,12 +3,22 @@ from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from apps.materials.stock_service import create_reserves_for_plan from apps.materials.stock_service import create_reserves_for_plan, delete_reserves_for_plan
from apps.materials.models import StockTransaction from apps.materials.models import StockTransaction
from apps.workrecords.services import sync_spreading_work_record from apps.workrecords.services import sync_spreading_work_record
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
class FertilizationPlanMergeError(Exception):
pass
class FertilizationPlanMergeConflict(FertilizationPlanMergeError):
def __init__(self, conflicts):
super().__init__('merge conflict')
self.conflicts = conflicts
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs): def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
pairs = { pairs = {
(int(field_id), int(fertilizer_id)) (int(field_id), int(fertilizer_id))
@@ -103,3 +113,84 @@ def move_fertilization_entries_for_variety_change(change):
moved_count += len(entries_to_move) moved_count += len(entries_to_move)
return moved_count return moved_count
@transaction.atomic
def merge_fertilization_plan_into(source_plan, target_plan):
if source_plan.id == target_plan.id:
raise FertilizationPlanMergeError('同じ施肥計画にはマージできません。')
if source_plan.year != target_plan.year:
raise FertilizationPlanMergeError('年度が異なる施肥計画にはマージできません。')
if source_plan.variety_id != target_plan.variety_id:
raise FertilizationPlanMergeError('品種が異なる施肥計画にはマージできません。')
if source_plan.is_confirmed or target_plan.is_confirmed:
raise FertilizationPlanMergeError('散布確定済みの施肥計画はマージできません。')
source_entries = list(
source_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
if not source_entries:
raise FertilizationPlanMergeError('移動元の施肥計画にマージ対象の entry がありません。')
source_pairs = {(entry.field_id, entry.fertilizer_id) for entry in source_entries}
target_entries = list(
target_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
target_pairs = {(entry.field_id, entry.fertilizer_id): entry for entry in target_entries}
conflicts = [
{
'field_id': entry.field_id,
'field_name': entry.field.name,
'fertilizer_id': entry.fertilizer_id,
'fertilizer_name': entry.fertilizer.name,
}
for entry in source_entries
if (entry.field_id, entry.fertilizer_id) in target_pairs
]
if conflicts:
raise FertilizationPlanMergeConflict(conflicts)
FertilizationEntry.objects.filter(
id__in=[entry.id for entry in source_entries]
).update(plan=target_plan)
target_plan.calc_settings = _merge_calc_settings(
target_plan.calc_settings,
source_plan.calc_settings,
)
target_plan.save()
create_reserves_for_plan(target_plan)
moved_count = len(source_entries)
deleted_source_plan = False
if not FertilizationEntry.objects.filter(plan=source_plan).exists():
delete_reserves_for_plan(source_plan)
source_plan.delete()
deleted_source_plan = True
else:
create_reserves_for_plan(source_plan)
return {
'moved_entry_count': moved_count,
'deleted_source_plan': deleted_source_plan,
}
def _merge_calc_settings(target_settings, source_settings):
merged = list(target_settings or [])
existing_fertilizer_ids = {
setting.get('fertilizer_id')
for setting in merged
if isinstance(setting, dict)
}
for setting in source_settings or []:
if not isinstance(setting, dict):
continue
fertilizer_id = setting.get('fertilizer_id')
if fertilizer_id in existing_fertilizer_ids:
continue
merged.append(setting)
existing_fertilizer_ids.add(fertilizer_id)
return merged

View File

@@ -0,0 +1,156 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from apps.fields.models import Field
from apps.materials.models import Material, StockTransaction
from apps.materials.stock_service import create_reserves_for_plan
from apps.plans.models import Crop, Variety
from .models import FertilizationEntry, FertilizationPlan, Fertilizer
class FertilizationPlanMergeTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(
username='merge-user',
password='secret12345',
)
self.client.force_authenticate(user=self.user)
crop = Crop.objects.create(name='水稲')
self.variety = Variety.objects.create(crop=crop, name='たちはるか特栽')
self.field_a = Field.objects.create(
name='足川北上',
address='高知県高岡郡',
area_tan='1.2000',
area_m2=1200,
owner_name='吉田',
group_name='',
display_order=1,
)
self.field_b = Field.objects.create(
name='足川南',
address='高知県高岡郡',
area_tan='0.8000',
area_m2=800,
owner_name='吉田',
group_name='',
display_order=2,
)
material_a = Material.objects.create(
name='高度化成14号',
material_type=Material.MaterialType.FERTILIZER,
)
material_b = Material.objects.create(
name='分げつ一発',
material_type=Material.MaterialType.FERTILIZER,
)
self.fertilizer_a = Fertilizer.objects.create(name='高度化成14号', material=material_a)
self.fertilizer_b = Fertilizer.objects.create(name='分げつ一発', material=material_b)
def test_merge_into_moves_entries_and_deletes_empty_source_plan(self):
target_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 元肥',
year=2026,
variety=self.variety,
calc_settings=[{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'}],
)
source_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
year=2026,
variety=self.variety,
calc_settings=[{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'}],
)
target_entry = FertilizationEntry.objects.create(
plan=target_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='3.00',
actual_bags='1.0000',
)
source_entry = FertilizationEntry.objects.create(
plan=source_plan,
field=self.field_b,
fertilizer=self.fertilizer_b,
bags='2.00',
actual_bags='2.0000',
)
create_reserves_for_plan(target_plan)
create_reserves_for_plan(source_plan)
response = self.client.post(
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
{'target_plan_id': target_plan.id},
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['moved_entry_count'], 1)
self.assertTrue(response.data['deleted_source_plan'])
source_entry.refresh_from_db()
self.assertEqual(source_entry.plan_id, target_plan.id)
self.assertFalse(FertilizationPlan.objects.filter(id=source_plan.id).exists())
target_plan.refresh_from_db()
self.assertEqual(
target_plan.calc_settings,
[
{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'},
{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'},
],
)
reserves = list(
StockTransaction.objects.filter(
fertilization_plan=target_plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).order_by('material__name')
)
self.assertEqual(len(reserves), 2)
self.assertEqual(
{(reserve.material_id, reserve.quantity) for reserve in reserves},
{
(self.fertilizer_a.material_id, Decimal(str(target_entry.bags))),
(self.fertilizer_b.material_id, Decimal(str(source_entry.bags))),
},
)
def test_merge_into_stops_on_field_fertilizer_conflict(self):
target_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 元肥',
year=2026,
variety=self.variety,
)
source_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
year=2026,
variety=self.variety,
)
FertilizationEntry.objects.create(
plan=target_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='3.00',
)
source_entry = FertilizationEntry.objects.create(
plan=source_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='2.00',
)
response = self.client.post(
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
{'target_plan_id': target_plan.id},
format='json',
)
self.assertEqual(response.status_code, 409)
self.assertEqual(len(response.data['conflicts']), 1)
source_entry.refresh_from_db()
self.assertEqual(source_entry.plan_id, source_plan.id)
self.assertTrue(FertilizationPlan.objects.filter(id=source_plan.id).exists())

View File

@@ -31,7 +31,12 @@ from .serializers import (
SpreadingSessionSerializer, SpreadingSessionSerializer,
SpreadingSessionWriteSerializer, SpreadingSessionWriteSerializer,
) )
from .services import sync_actual_bags_for_pairs from .services import (
FertilizationPlanMergeConflict,
FertilizationPlanMergeError,
merge_fertilization_plan_into,
sync_actual_bags_for_pairs,
)
class FertilizerViewSet(viewsets.ModelViewSet): class FertilizerViewSet(viewsets.ModelViewSet):
@@ -123,6 +128,55 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"' response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response return response
@action(detail=True, methods=['get'])
def merge_targets(self, request, pk=None):
source_plan = self.get_object()
targets = (
FertilizationPlan.objects
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
.exclude(id=source_plan.id)
.prefetch_related('entries')
.order_by('-updated_at', 'id')
)
data = [
{
'id': plan.id,
'name': plan.name,
'field_count': plan.entries.values('field').distinct().count(),
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
'is_confirmed': plan.is_confirmed,
}
for plan in targets
]
return Response(data)
@action(detail=True, methods=['post'])
def merge_into(self, request, pk=None):
source_plan = self.get_object()
target_plan_id = request.data.get('target_plan_id')
if not target_plan_id:
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
try:
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
except FertilizationPlan.DoesNotExist:
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
try:
result = merge_fertilization_plan_into(source_plan, target_plan)
except FertilizationPlanMergeConflict as exc:
return Response(
{
'error': '競合する圃場・肥料があるためマージできません。',
'conflicts': exc.conflicts,
},
status=status.HTTP_409_CONFLICT,
)
except FertilizationPlanMergeError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(result)
class CandidateFieldsView(APIView): class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す""" """作付け計画から圃場候補を返す"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View File

@@ -1,4 +1,5 @@
from django.db import transaction from django.db import transaction
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.plans.models import Plan from apps.plans.models import Plan
@@ -34,6 +35,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True) items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True) work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
item_count = serializers.SerializerMethodField() item_count = serializers.SerializerMethodField()
total_area_tan = serializers.SerializerMethodField()
class Meta: class Meta:
model = LeveeWorkSession model = LeveeWorkSession
@@ -45,6 +47,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
'notes', 'notes',
'work_record_id', 'work_record_id',
'item_count', 'item_count',
'total_area_tan',
'items', 'items',
'created_at', 'created_at',
'updated_at', 'updated_at',
@@ -53,6 +56,10 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
def get_item_count(self, obj): def get_item_count(self, obj):
return len(obj.items.all()) return len(obj.items.all())
def get_total_area_tan(self, obj):
total = sum((item.field.area_tan or Decimal('0')) for item in obj.items.all())
return str(total)
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer): class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
field = serializers.IntegerField() field = serializers.IntegerField()

View File

@@ -0,0 +1,58 @@
from django.test import TestCase
from apps.fields.models import Field
from apps.plans.models import Crop, Plan, Variety
from .models import LeveeWorkSession, LeveeWorkSessionItem
from .serializers import LeveeWorkSessionSerializer
class LeveeWorkSessionSerializerTests(TestCase):
def test_total_area_tan_is_included(self):
crop = Crop.objects.create(name='水稲')
variety = Variety.objects.create(crop=crop, name='にこまる')
field_a = Field.objects.create(
name='足川北上',
address='高知県高岡郡',
area_tan='1.2000',
area_m2=1200,
owner_name='吉田',
group_name='',
display_order=1,
)
field_b = Field.objects.create(
name='足川南',
address='高知県高岡郡',
area_tan='0.8000',
area_m2=800,
owner_name='吉田',
group_name='',
display_order=2,
)
plan_a = Plan.objects.create(field=field_a, year=2026, crop=crop, variety=variety, notes='')
plan_b = Plan.objects.create(field=field_b, year=2026, crop=crop, variety=variety, notes='')
session = LeveeWorkSession.objects.create(
year=2026,
date='2026-04-06',
title='水稲畔塗',
notes='',
)
LeveeWorkSessionItem.objects.create(
session=session,
field=field_a,
plan=plan_a,
crop_name_snapshot='水稲',
variety_name_snapshot='にこまる',
)
LeveeWorkSessionItem.objects.create(
session=session,
field=field_b,
plan=plan_b,
crop_name_snapshot='水稲',
variety_name_snapshot='にこまる',
)
data = LeveeWorkSessionSerializer(session).data
self.assertEqual(data['item_count'], 2)
self.assertEqual(data['total_area_tan'], '2.0000')

37
deploy_local.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# ローカル本番同等環境の起動スクリプト
# 使用: bash deploy_local.sh
set -e
cd "$(dirname "$0")"
echo "=== KeinaSystem ローカル本番環境 ==="
# .env ファイル確認
if [ ! -f ".env" ]; then
echo "エラー: .env ファイルがありません"
echo " .env.production.example を .env にコピーして値を設定してください"
exit 1
fi
echo "[1/4] 停止..."
docker compose -f docker-compose.local.yml down
echo "[2/4] ビルド..."
docker compose -f docker-compose.local.yml build
echo "[3/4] 起動..."
docker compose -f docker-compose.local.yml up -d
echo "[4/4] マイグレーション..."
sleep 5
docker compose -f docker-compose.local.yml exec backend python manage.py migrate
echo ""
echo "=== 起動完了 ==="
docker compose -f docker-compose.local.yml ps
echo ""
echo " フロントエンド: http://localhost:3000"
echo " バックエンドAPI: http://localhost:8000/api/"
echo ""
echo "DBをサーバーと同期する場合: bash sync_db.sh"

59
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,59 @@
# ローカルでの本番同等テスト用
# Traefikなし、ポート直接公開、本番用Dockerfileを使用
# 使用: docker compose -f docker-compose.local.yml up -d
services:
db:
image: postgis/postgis:16-3.4
container_name: keinasystem_db
environment:
POSTGRES_DB: keinasystem
POSTGRES_USER: keinasystem
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres_data_local:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keinasystem -d keinasystem"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
container_name: keinasystem_backend
environment:
DB_NAME: keinasystem
DB_USER: keinasystem
DB_PASSWORD: ${DB_PASSWORD}
DB_HOST: db
DB_PORT: 5432
SECRET_KEY: ${SECRET_KEY}
DEBUG: "False"
ALLOWED_HOSTS: localhost,127.0.0.1
CORS_ALLOWED_ORIGINS: http://localhost:3000
MAIL_API_KEY: ${MAIL_API_KEY}
FRONTEND_URL: http://localhost:3000
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
args:
NEXT_PUBLIC_API_URL: http://localhost:8000
container_name: keinasystem_frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres_data_local:

View File

@@ -0,0 +1,298 @@
# マスタードキュメント:ナビゲーション再編
> **作成**: 2026-04-07
> **最終更新**: 2026-04-07
> **対象機能**: グローバルナビゲーション再編トップメニュー整理・カテゴリ再編・PC/スマホ共通情報設計)
> **実装状況**: 仕様策定完了・未実装
---
## 概要
機能追加に伴って共通ナビゲーションのトップレベル項目が増え、画面名ベースで並ぶ構造になってきたため、業務カテゴリ単位で再整理する。
今回の再編では、トップナビを `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類に絞り、個別画面はドロップダウン配下に集約する。
これにより、利用者が「どの画面名か」ではなく「何をしたいか」で画面を探せる状態を目指す。
また、URL 構造とメニュー構成は意図的に分離して扱う。既存 URL は安定性を優先して原則維持し、アクティブ判定はナビ定義側で吸収する。
### 機能スコープIN / OUT
| IN今回対象 | OUT今回対象外 |
|---|---|
| PC ヘッダーのトップメニュー再編 | 各業務画面自体のUI改修 |
| スマホ用ハンバーガーメニュー再編 | 権限別メニュー出し分け |
| メニュー分類、並び順、開閉仕様 | お気に入り、ピン留め |
| アクティブ判定ルール整理 | ダッシュボード内容の刷新 |
| `NavGroup` / `NavItem` ベースのメニュー定義整理 | URL の全面変更 |
| `作物` `品種` を将来のマスター画面として位置づけ | 矢印キー移動を含む高度なメニューアクセシビリティ |
---
## 背景と判断理由
### 現状の課題
- 横並びのトップメニュー数が多く、目的の画面を探しにくい
- `計画` `実績` `設定` `補助機能` が同じ粒度で並んでいる
- 画面名ベースで項目が増えており、業務単位でまとまっていない
- 今後も機能追加が続くと、視認性と拡張性の両方が悪化する
### 採用した考え方
1. トップレベルは日常的に使う業務カテゴリだけに絞る
2. 個別機能名ではなく、業務単位で束ねる
3. URL はリソース識別子として安定性を優先し、メニュー構成とは分離する
4. 例外的な URL 衝突のみナビ定義側のルールで吸収する
### 関連議論
- 判断理由、論点の切り分け、URL とメニューの関係整理は Gitea Issue `#13` に残す
- 実装向けの決定事項は `改善案/ナビゲーション再編仕様書.md` に集約する
- 本ドキュメントは、その内容を長期参照用に固定化したものとして扱う
---
## 情報設計
### トップレベル構成
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
右上ユーザー操作:
- パスワード変更
- ログアウト
### カテゴリ構成
#### ホーム
- ダッシュボード
#### 計画
- 作付け計画
- 施肥計画
- 田植え計画
- 運搬計画
#### 実績
- 散布実績
- 畔塗記録
- 作業記録
#### マスター
- 圃場管理
- 作物
- 品種
- 資材マスタ
- 肥料マスタ
#### 帳票・連携
- 在庫管理
- 帳票出力
- データ取込
- 気象
- メール
### この分類にした理由
#### マスター
- `圃場管理` は圃場マスタとして独立性が高い
- `作物` `品種` も本来マスター管理である
- `資材マスタ` `肥料マスタ` はすでに独立画面が存在する
そのため、基礎データ管理を `マスター` に集約する。
#### 帳票・連携
- `在庫管理` `帳票出力` `データ取込` `気象` `メール` は完全に同質ではない
- ただし、いずれも主作業そのものではなく、補助・参照・出力・連携の性質が強い
そのため、トップ階層を増やしすぎないための受け皿として `帳票・連携` にまとめる。
補足:
- `データ取込` は日常操作ではなく、年度切替時や初期設定時の補助導線とみなす
- `メール` は個別トップにしない
- `設定` は現状パスワード変更のみなので、右上ユーザー操作に残す
---
## 画面と所属カテゴリ
| カテゴリ | ラベル | パス |
|---|---|---|
| ホーム | ダッシュボード | `/dashboard` |
| 計画 | 作付け計画 | `/allocation` |
| 計画 | 施肥計画 | `/fertilizer` |
| 計画 | 田植え計画 | `/rice-transplant` |
| 計画 | 運搬計画 | `/distribution` |
| 実績 | 散布実績 | `/fertilizer/spreading` |
| 実績 | 畔塗記録 | `/levee-work` |
| 実績 | 作業記録 | `/workrecords` |
| マスター | 圃場管理 | `/fields` |
| マスター | 作物 | 未実装allocation 内管理を独立予定) |
| マスター | 品種 | 未実装allocation 内管理を独立予定) |
| マスター | 資材マスタ | `/materials/masters` |
| マスター | 肥料マスタ | `/fertilizer/masters` |
| 帳票・連携 | 在庫管理 | `/materials` |
| 帳票・連携 | 帳票出力 | `/reports` |
| 帳票・連携 | データ取込 | `/import` |
| 帳票・連携 | 気象 | `/weather` |
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
| 帳票・連携 > メール | メールルール | `/mail/rules` |
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
---
## URL とナビゲーションの関係
### 基本原則
1. URL はリソース・機能識別子として安定性を優先する
2. メニュー構成とは意図的に分離して扱う
3. メニュー再編のたびに URL を変更しない
4. アクティブ判定はナビ定義側のルールで吸収する
### 採用理由
- URL をメニュー階層に合わせて変更すると、既存リンク、ブックマーク、テストへの影響が大きい
- メニュー構成は将来も変わりうるため、URL にメニュー階層を埋め込むと変更コストが増える
### 衝突する既存パス
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|---|---|---|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
通常判定:
- `/fertilizer` `/fertilizer/new` `/fertilizer/[id]/edit``施肥計画`
- `/materials` `/materials?tab=...``在庫管理`
---
## 表示仕様
### PC
- 左: ブランド名 `KeinaSystem`
- 中央: トップメニュー 5 項目
- 右: パスワード変更、ログアウト
表示ルール:
- `ホーム` は単独リンク
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン
- 開いているメニューがある状態で別メニューを開く場合は、前のメニューを閉じる
- メニュー外クリック、`Esc` キーで閉じる
- 項目選択後は遷移して閉じる
### スマホ
- ハンバーガーメニューを採用する
- `ホーム` は単独リンクで `/dashboard` へ遷移する
- それ以外のカテゴリはアコーディオン形式で開閉する
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
- 項目タップ後はメニューを閉じて画面遷移する
---
## アクセシビリティ方針
- トップメニューへキーボードでフォーカス移動できること
- `Enter` または `Space` でドロップダウンを開閉できること
- ドロップダウン展開後、各項目へ `Tab` で到達できること
- `Esc` で閉じられること
- 現在位置が視覚的に分かること
### 初期実装でやらないこと
- 矢印キーによるドロップダウン項目間移動
これは Phase 1 の必須要件には含めず、将来のアクセシビリティ強化項目として扱う。
---
## 実装方針
### メニュー定義
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
```ts
type NavItem = {
label: string;
href: string;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
items?: NavItem[];
};
```
方針:
- グループ構成そのものが定義から読み取れることを優先する
- 通常ケースは `href` ベースで扱う
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
### Next.js App Router との関係
- Route Groups は、URL を変えずにコード構造を整理する手段として有効
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
---
## 段階導入
### Phase 1
- トップナビを 5 分類へ再編する
- `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみ
- `作物` `品種` はマスター体系には含めるが、独立画面がまだないため Phase 1 ではメニューに表示しない
- PC / スマホともに同じ情報設計にそろえる
### Phase 2
- `作物管理` `品種管理` を独立画面として追加
- `帳票・連携` 内の `メール` を必要に応じてサブグループ化
### Phase 3
- 将来マルチユーザー化した場合のみ再検討
- 単独利用前提の間は実施対象外
---
## 受け入れ条件
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
- 各画面でアクティブ状態が期待通りに表示されること
- PC とスマホで同じカテゴリ構成になっていること
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
---
## 参照
- 議論の背景・判断理由: Gitea Issue `#13 メニューがごちゃごちゃしてきたので、整理する`
- 実装向け詳細仕様: `改善案/ナビゲーション再編仕様書.md`

View File

@@ -0,0 +1,639 @@
# マスタードキュメント:農薬散布管理機能
> **作成**: 2026-04-09
> **最終更新**: 2026-04-09
> **対象機能**: 農薬散布管理(農薬マスタ・散布記録・使用回数チェック・特別栽培向け成分数集計)
> **実装状況**: 未着手(仕様確定済み)
> **Gitea Issue**: akira/keinasystem#18
---
## 概要
農業生産者が散布した農薬を記録・管理し、農薬取締法に基づく使用基準(製品ごと・有効成分ごとの使用回数制限)への適合確認と、特別栽培認証用の成分数集計を行う機能。
### 機能スコープIN / OUT
| IN実装対象 | OUT対象外 |
|---|---|
| 農薬マスタ管理CRUD | 農薬の在庫管理・購入管理 |
| 農林水産省サイトからの農薬情報自動取得 | 農薬費用の管理 |
| 散布イベント記録(圃場/グループ/作物/品種対象) | 希釈液の量管理 |
| 製品ごとの使用回数チェック(年度×作物) | 農薬の廃棄記録 |
| 有効成分ごとの総使用回数チェック(年度×作物) | 農薬散布マップGIS |
| 特別栽培用:節減対象農薬の使用成分数集計 | 農薬の処方箋・防除暦の自動作成 |
| 回数超過アラート表示 | |
---
## 使用回数カウントのルール
農薬の使用回数は **製品単位****有効成分単位** の2軸で管理する。
### ルール1製品ごとの使用回数
農薬製品(例: 住化スミチオン乳剤を1シーズンに使用した回数 ≤ 登録情報の「本剤の使用回数」上限。
### ルール2有効成分ごとの総使用回数
同一有効成分を含む複数製品を使用した場合、その有効成分の総使用回数として合算カウントする。
```
「MEP乳剤A上限3回」と「MEP乳剤B上限3回」、MEP成分の総上限3回
→ A剤2回 + B剤1回 = 合計3回 → OK
→ A剤2回 + B剤2回 = 合計4回 → 超過!
```
### ルール3使用時期別カウント
育苗期・本圃期など時期別に別カウントになる場合がある(登録情報のテキストとして記録)。
システムでは現フェーズで時期別の自動判定は行わず、登録情報テキストを参照情報として表示する。
### カウント対象外農薬(節減対象外)
以下の農薬は使用回数・成分数のカウントから除外する(`is_non_target` フラグで管理):
- 展着剤(`is_spreader` フラグでも管理)
- 有機JAS別表2に掲げる農薬除虫菊乳剤・硫黄剤・天敵生物農薬・性フェロモン剤等
- 化学合成でないと認められた農薬(カスガマイシン剤・ポリオキシン剤・バリダマイシン剤等)
### 特別栽培向け成分数集計
「節減対象農薬(`is_non_target=False`)の有効成分(`is_active=True`)が何種類使われたか」を年度×作物単位でカウントする。
上限はなく、報告用の集計値として表示する。
---
## データモデル
### Pesticide農薬マスタ
**アプリ**: `apps/pesticide`
**テーブル名**: `pesticide_pesticide`
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| name | CharField(200) | required | 農薬名(例: 住化スミチオン乳剤) |
| pesticide_type | CharField(100) | blank | 農薬の種類(例: MEP乳剤) |
| registration_number | CharField(20) | blank | 農薬登録番号(公式登録番号) |
| system_id | CharField(20) | blank | 農水省サイトの内部ID詳細URLに使用 |
| purpose | CharField(100) | blank | 用途(例: 殺虫剤) |
| formulation | CharField(100) | blank | 剤型(例: 乳剤) |
| toxicity | CharField(20) | blank | 製剤毒性(普/毒/劇等) |
| is_spreader | BooleanField | default=False | 展着剤フラグ |
| is_non_target | BooleanField | default=False | 節減対象外フラグ(カウント除外) |
| notes | TextField | blank | 備考 |
| fetched_at | DateTimeField | null=True | 農水省サイトからの最終取得日時 |
| created_at | DateTimeField | auto | |
| updated_at | DateTimeField | auto | |
- `name` は unique 制約なし(同名で複数登録番号が存在しうる)
- `is_spreader=True` の場合、`is_non_target` も自動的に `True` 扱いとする
### PesticideIngredient有効成分
**テーブル名**: `pesticide_pesticideingredient`
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| pesticide | FK(Pesticide) | CASCADE | |
| name | CharField(200) | required | 成分名称(例: MEP |
| concentration | CharField(100) | blank | 含有濃度(例: 50.0% |
| is_active | BooleanField | default=True | 有効成分かどうかFalse = その他成分) |
- `unique_together = ['pesticide', 'name']`
### PesticideIngredientLimit有効成分の総使用回数上限作物別
**テーブル名**: `pesticide_pesticideingredientlimit`
農水省の「○○を含む農薬の総使用回数」は作物ごとに異なりうるため、有効成分本体とは分離して作物別に保持する。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| pesticide | FK(Pesticide) | CASCADE | 取得元農薬 |
| ingredient_name | CharField(200) | required | 成分名称(例: MEP |
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
| max_total_uses | IntegerField | null=True | この成分を含む農薬の総使用回数上限 |
| use_timing_note | TextField | blank | 使用時期別制限のテキスト(例: 種もみへの処理は1回以内、… |
- `unique_together = ['pesticide', 'ingredient_name', 'crop_name']`
- 同一成分・同一作物であれば製品が異なっても上限値は同一(農水省登録情報の仕様)
- 保存時バリデーション: 同一 `ingredient_name + crop_name` の既存レコードと異なる `max_total_uses` を保存しようとした場合はエラーにする
- 使用回数チェック API の `ingredient_usage.max_total_uses` は、同一 `ingredient_name + crop_name` の値が一意であることを前提に単一値を返す
### PesticideProductLimit製品の使用回数上限作物別
**テーブル名**: `pesticide_pesticideproductlimit`
農水省の適用表は作物ごとに上限が異なるため、作物名をキーとして保存する。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| pesticide | FK(Pesticide) | CASCADE | |
| crop_name | CharField(200) | required | 作物名(農水省登録情報の表記、例: 稲) |
| max_uses | IntegerField | required | 本剤の使用回数上限 |
| use_timing_note | TextField | blank | 使用時期・条件の補足テキスト |
- `unique_together = ['pesticide', 'crop_name']`
### PesticideCropAlias農水省作物名と内部作物の対応
**テーブル名**: `pesticide_pesticidecropalias`
農水省の適用表上の作物名と、内部 `plans.Crop` の作物を対応付けるための正規化テーブル。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| crop | FK(plans.Crop) | PROTECT | 内部作物 |
| alias_name | CharField(200) | required, unique | 農水省登録情報の作物名(例: 稲, 水稲) |
| is_primary | BooleanField | default=False | 代表表記かどうか |
- 使用回数チェック時は `crop_id` から本テーブルを逆引きし、`PesticideProductLimit.crop_name` / `PesticideIngredientLimit.crop_name` と照合する
- 初期データ例: `Crop=水稲` に対し `alias_name=稲`, `alias_name=水稲` を登録
### SprayEvent散布イベント
**テーブル名**: `pesticide_sprayevent`
1回の散布作業を1件として記録する。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| year | IntegerField | required | 年度(集計フィルタ用) |
| date | DateField | required | 散布日 |
| target_type | CharField(20) | required | 対象種別: `field` / `group` / `crop` / `variety` |
| target_field | FK(fields.Field) | null=True, PROTECT | 対象が圃場の場合 |
| target_group | CharField(50) | blank | 対象が圃場グループの場合group_name |
| target_crop | FK(plans.Crop) | null=True, PROTECT | 対象が作物の場合 |
| target_variety | FK(plans.Variety) | null=True, PROTECT | 対象が品種の場合 |
| notes | TextField | blank | 備考 |
| created_at | DateTimeField | auto | |
| updated_at | DateTimeField | auto | |
#### target_type 別のバリデーション
| target_type | 必須フィールド | 意味 |
|---|---|---|
| `field` | target_field | 特定の圃場1筆に散布 |
| `group` | target_group | 同一 group_name の全圃場に散布 |
| `crop` | target_crop | 特定の作物に対して散布(作付け計画と照合) |
| `variety` | target_variety | 特定の品種に対して散布(作付け計画と照合) |
- 保存時に全対象圃場を `SprayEventResolvedField` として確定保存し、後日の作付け変更やグループ名変更があっても過去実績の集計結果が変わらないようにする
### SprayEventResolvedField散布イベント対象圃場スナップショット
**テーブル名**: `pesticide_sprayeventresolvedfield`
`target_type=group` / `crop` / `variety` のように複数圃場へ展開される散布について、保存時点で対象圃場を確定保存する。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| event | FK(SprayEvent) | CASCADE | |
| field | FK(fields.Field) | PROTECT | 対象圃場 |
| field_name_snapshot | CharField(100) | required | 保存時点の圃場名 |
| group_name_snapshot | CharField(50) | blank | 保存時点のグループ名 |
| crop_name_snapshot | CharField(100) | required | 保存時点の作物名 |
| variety_name_snapshot | CharField(100) | blank | 保存時点の品種名 |
- `unique_together = ['event', 'field']`
- `target_type=field` の場合も 1 行作成しておくと、集計ロジックを統一しやすい
### SprayEventPesticide散布農薬明細
**テーブル名**: `pesticide_sprayeventpesticide`
1つの散布イベントに複数農薬を紐づける。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | BigAutoField | PK | |
| event | FK(SprayEvent) | CASCADE | |
| pesticide | FK(Pesticide) | PROTECT | 使用農薬 |
| dilution_ratio | CharField(50) | blank | 希釈倍率(例: 1000倍 |
| amount_used | CharField(50) | blank | 使用量(例: 500mL、単位込みで自由記述 |
| notes | TextField | blank | 備考 |
- `pesticide` は PROTECT使用済み農薬は削除不可
- `unique_together = ['event', 'pesticide']`同一イベント内で同じ農薬を2回登録不可
---
## 使用回数集計の仕組み
### 集計単位
**年度 × 作物** を基本単位とする(農薬取締法上、使用回数は作物単位で管理する義務がある)。
- 集計対象作物は `SprayEventResolvedField.crop_name_snapshot` を正とする(圃場ごとに記録)
- `target_type=field`/`group`/`crop`/`variety` の違いにかかわらず、保存時に全対象圃場の `SprayEventResolvedField` を作成し、各圃場の作物をスナップショットとして保持する
- **グループ内に複数作物が混在する場合**、同一の散布イベント・散布農薬でも作物ごとに使用回数がカウントされる。例グループ内に「水稲」3筆・「大豆」1筆が含まれる場合、そのイベントの農薬は水稲の回数にも大豆の回数にも +1 される
- 使用回数上限の照合は、`SprayEventResolvedField.crop_name_snapshot``PesticideCropAlias``PesticideProductLimit` / `PesticideIngredientLimit` の順に行う
### 製品使用回数の集計
1イベント = 1散布作業 = 1回。`unique_together=['event', 'pesticide']` により同一イベント内で同一農薬は1行しか存在しないため、イベント単位でカウントして正確。
```
製品使用回数年度Y・作物C・農薬P=
COUNT(DISTINCT SprayEvent.id)
where SprayEvent に SprayEventPesticide(pesticide=P) が紐づく
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
かつ SprayEvent.year = Y
```
※ 1イベントで複数圃場に散布しても「1回」とカウントする1イベント=1散布作業
### 有効成分総使用回数の集計
1回の散布作業イベント= 有効成分の使用回数1回。同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布 = 1回の使用」と解釈される。
```
有効成分総使用回数年度Y・作物C・成分名I=
COUNT(DISTINCT SprayEvent.id)
where SprayEvent に SprayEventPesticide が紐づく
かつ SprayEventPesticide.pesticide の PesticideIngredient に
name=I かつ is_active=True のものが存在する
かつ SprayEventPesticide.pesticide.is_non_target=False
かつ SprayEvent に SprayEventResolvedField(crop_name_snapshot=C) が紐づく
かつ SprayEvent.year = Y
```
`SprayEventResolvedField` は圃場ごとに複数行あるため、結合で行が増えても `DISTINCT SprayEvent.id` で 1散布作業を1回だけ数える
### 特別栽培・使用成分数の集計
```
使用成分数年度Y・作物C=
COUNT(DISTINCT PesticideIngredient.name)
where 上記条件年度Y・作物Cの散布イベントで使用された農薬に含まれる
かつ PesticideIngredient.is_active=True
かつ SprayEventPesticide.pesticide.is_non_target=False
```
---
## API エンドポイント
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
### 農薬マスタ
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/pesticide/pesticides/` | 一覧取得 |
| POST | `/api/pesticide/pesticides/` | 新規作成 |
| GET | `/api/pesticide/pesticides/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/pesticide/pesticides/{id}/` | 更新 |
| DELETE | `/api/pesticide/pesticides/{id}/` | 削除(使用中は 400 |
| POST | `/api/pesticide/pesticides/fetch/` | 農水省サイトから情報取得 |
農薬マスタ レスポンス例:
```json
{
"id": 1,
"name": "住化スミチオン乳剤",
"pesticide_type": "MEP乳剤",
"registration_number": "4962",
"system_id": "4962",
"purpose": "殺虫剤",
"formulation": "乳剤",
"toxicity": "普",
"is_spreader": false,
"is_non_target": false,
"notes": "",
"fetched_at": "2026-04-09T10:00:00Z",
"ingredients": [
{
"id": 1,
"name": "MEP",
"concentration": "50.0%",
"is_active": true
}
],
"product_limits": [
{
"id": 1,
"crop_name": "稲",
"max_uses": 2,
"use_timing_note": "収穫21日前まで"
}
],
"ingredient_limits": [
{
"id": 1,
"ingredient_name": "MEP",
"crop_name": "稲",
"max_total_uses": 3,
"use_timing_note": "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
}
],
"crop_aliases": [
{
"crop": 1,
"crop_name": "水稲",
"alias_name": "稲",
"is_primary": true
}
]
}
```
#### `POST /api/pesticide/pesticides/fetch/`
農水省農薬登録情報提供システムから農薬情報を取得してマスタに保存する。
取得に失敗した場合は `fetch_error` を返し、手動入力に切り替える。
リクエスト:
```json
{
"name": "スミチオン"
}
```
レスポンス(成功):
```json
{
"status": "ok",
"candidates": [
{
"system_id": "4962",
"name": "住化スミチオン乳剤",
"pesticide_type": "MEP乳剤",
"registration_number": "4962"
},
{
"system_id": "4991",
"name": "ホクコースミチオン乳剤",
"pesticide_type": "MEP乳剤",
"registration_number": "4991"
}
]
}
```
候補が複数ある場合はフロントで選択させ、選択後に詳細取得リクエストを投げる:
```json
{ "system_id": "4962" }
```
レスポンス(失敗):
```json
{
"status": "error",
"message": "農林水産省サイトへの接続に失敗しました。手動で入力してください。"
}
```
### 散布イベント
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/pesticide/events/?year={year}` | 年度別一覧 |
| POST | `/api/pesticide/events/` | 新規作成 |
| GET | `/api/pesticide/events/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/pesticide/events/{id}/` | 更新 |
| DELETE | `/api/pesticide/events/{id}/` | 削除 |
散布イベント POST リクエスト例(圃場グループを対象に複数農薬散布):
```json
{
"year": 2026,
"date": "2026-05-10",
"target_type": "group",
"target_group": "田中エリア",
"notes": "曇り、風弱し",
"pesticides": [
{
"pesticide": 1,
"dilution_ratio": "1000倍",
"amount_used": "500mL"
},
{
"pesticide": 3,
"dilution_ratio": "2000倍",
"amount_used": "200mL"
}
]
}
```
散布イベント レスポンス例:
```json
{
"id": 10,
"year": 2026,
"date": "2026-05-10",
"target_type": "group",
"target_group": "田中エリア",
"target_display": "田中エリア(グループ)",
"resolved_fields": [
{
"field": 5,
"field_name_snapshot": "田中上",
"group_name_snapshot": "田中エリア",
"crop_name_snapshot": "水稲",
"variety_name_snapshot": "コシヒカリ"
}
],
"notes": "曇り、風弱し",
"pesticides": [
{
"id": 15,
"pesticide": 1,
"pesticide_name": "住化スミチオン乳剤",
"dilution_ratio": "1000倍",
"amount_used": "500mL"
}
],
"created_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
}
```
### 使用回数チェック
#### `GET /api/pesticide/usage-summary/?year={year}&crop_id={crop_id}`
年度×作物単位で使用回数の集計・チェック結果を返す。
レスポンス例:
```json
{
"year": 2026,
"crop_id": 1,
"crop_name": "水稲",
"crop_aliases": ["稲", "水稲"],
"product_usage": [
{
"pesticide_id": 1,
"pesticide_name": "住化スミチオン乳剤",
"used_count": 2,
"max_uses": 2,
"remaining": 0,
"is_over": false
}
],
"ingredient_usage": [
{
"ingredient_name": "MEP",
"used_count": 2,
"max_total_uses": 3,
"remaining": 1,
"is_over": false,
"products_used": ["住化スミチオン乳剤"]
}
],
"component_count": 2,
"has_violation": false
}
```
---
## 農水省サイトスクレイピング仕様
### 対象サイト
農林水産省 農薬登録情報提供システム
URL: `https://pesticide.maff.go.jp/`
### アクセスフロー
```
1. GET /agricultural-chemicals/name-search/
→ JSESSIONID クッキー + CSRF トークン(フォーム埋め込み)取得
2. POST /agricultural-chemicals/name-search
Content-Type: application/x-www-form-urlencoded
Body: _csrf=<token>&agriculturalChemicalsName=<農薬名>&agriculturalChemicalsType=
→ 302 リダイレクト先: /agricultural-chemicals/list
3. GET /agricultural-chemicals/list
→ 検索結果一覧 HTML
→ <a href="/agricultural-chemicals/details/{system_id}"> からリンク抽出
4. GET /agricultural-chemicals/details/{system_id}
→ 詳細ページ HTML → 下記データをパース
```
### 詳細ページ パース項目
**基本情報テーブル(`th[scope=col]` + `td` ペア):**
| th テキスト | 取得項目 | 保存先 |
|---|---|---|
| 登録番号 | 登録番号 | `registration_number` |
| 農薬の種類 | 種類名 | `pesticide_type` |
| 農薬の名称 | 農薬名 | `name` |
| 用途 | 用途 | `purpose` |
| 剤型 | 剤型 | `formulation` |
| 製剤毒性 | 毒性区分 | `toxicity` |
**有効成分テーブル:**
- 「有効成分」行: `is_active=True`、成分名・含有濃度を取得
- 「その他成分」行: `is_active=False`
**適用表(作物×病害虫ごとの行):**
各行のカラム(`data-label` 属性でカラム識別):
| data-label | 取得項目 | 保存先 |
|---|---|---|
| 作物名 | 作物名 | `PesticideProductLimit.crop_name` |
| 本剤の使用回数 | 「N回以内」から N を抽出 | `PesticideProductLimit.max_uses` |
| 使用時期 | テキストそのまま | `PesticideProductLimit.use_timing_note` |
| `{成分名}を含む農薬の総使用回数` | 「N回以内(...)」から N と補足を抽出 | `PesticideIngredientLimit.max_total_uses` / `use_timing_note` |
**「総使用回数」テキストのパース規則:**
```
入力例: "3回以内(種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内)"
→ max_total_uses = 3
→ use_timing_note = "種もみへの処理は1回以内、育苗箱散布は1回以内、本田では2回以内"
正規表現: r'(\d+)回以内(?:\((.+)\))?'
```
**整合性チェック:**
- 同一 `ingredient_name + crop_name` に対して既存の `PesticideIngredientLimit.max_total_uses` と異なる値が取得された場合、その農薬の自動取込はエラーとし、手動確認を促す
- `use_timing_note` の差異は許容し、より詳細なテキストで上書きしてよい
### 実装場所
`apps/pesticide/management/commands/fetch_pesticide.py`
Django management command として実装。APIエンドポイントから呼び出す。
### 注意事項
- セッション(`requests.Session`を使用し、クッキーとCSRFを維持する
- アクセスは農薬マスタ登録時の1件ずつに限定バルク取得は行わない
- 農水省サイトの内部ID`system_id`)と農薬の公式登録番号は別物
- タイムアウト: 10秒
- 適用表の作物名は `PesticideCropAlias` で内部 `Crop` と対応付ける前提で保存する
---
## 画面仕様
### 農薬マスタ画面(`/pesticide/`
- 登録済み農薬の一覧表示
- 農薬名で検索 → 農水省サイトから候補を取得 → 選択して詳細取得 → 保存
- 取得失敗時は手動入力フォームに切り替え
- 展着剤フラグ・節減対象外フラグの編集
### 散布記録入力画面(`/pesticide/events/new`
- 散布日・年度入力
- 対象種別(圃場/グループ/作物/品種)選択 → 対象を選択
- 農薬を追加(複数可): 農薬マスタから選択 + 希釈倍率 + 使用量
- 保存時に使用回数チェックを実行し、超過がある場合は警告を表示(保存はブロックしない)
### 使用回数チェック画面(`/pesticide/usage`
- 年度・作物でフィルタ
- **製品使用回数テーブル**: 農薬名 / 使用回数 / 上限 / 残回数(超過時は赤表示)
- **有効成分総使用回数テーブル**: 成分名 / 使用回数 / 上限 / 残回数 / 使用製品一覧(超過時は赤表示)
- **特別栽培欄**: 節減対象農薬の使用成分数(報告用)
---
## 設計判断と制約
1. **散布対象の特定**: `target_type` + 対象FK/文字列で柔軟に対応。保存時に `SprayEventResolvedField` で対象圃場と作物を確定保存する。作付け計画Planはあくまで保存時の解決に使うだけで、集計の正源ではない。
2. **使用回数上限は作物別に保持**: 同一農薬でも作物ごとに上限が異なるため `PesticideProductLimit``PesticideIngredientLimit` を作物別に複数行保持する。
3. **作物名の照合は別名テーブルで吸収**: 農水省表記の「稲」と内部の「水稲」のような差異を吸収するため、`PesticideCropAlias` を必須とする。
4. **散布対象は保存時に確定保存する**: 後日のグループ名変更や作付け変更で過去実績の集計結果が変わらないよう、`SprayEventResolvedField` に圃場・作物をスナップショット保存する。`SprayEvent` 自体には作物情報を持たない。
5. **有効成分総使用回数も「1イベント=1回」**: 同一成分を含む複数製品を同一イベントで施用することは実務上なく、仮に混合散布しても農薬取締法上「1回の散布=1回の使用」。製品使用回数と同様に `COUNT(DISTINCT SprayEvent.id)` で集計する。`SprayEventResolvedField` との結合で行が増えても `DISTINCT` で正確にカウントできる。
6. **総使用回数はテキストパース**: 農水省サイトの「○○を含む農薬の総使用回数」カラムから正規表現で数値を抽出する。
7. **有効成分上限の整合性は保存時に保証する**: 同一 `ingredient_name + crop_name``max_total_uses` は製品をまたいで一致している前提とし、異なる値を保存しようとした場合はエラーにする。
8. **保存はブロックしない**: 使用回数超過は警告表示のみ。農薬散布の記録は法的義務があるため、超過でも保存できるようにする。
9. **`SprayEventPesticide.pesticide` は PROTECT**: 散布記録に使用中の農薬は削除不可。
10. **成分集計は `is_active=True` のみ対象**: 「その他成分」は総使用回数・特別栽培の成分数集計に含めない。
11. **`is_spreader=True``is_non_target` 扱い**: 展着剤はカウント除外のため、展着剤フラグをセットすれば節減対象外フラグも自動的に True 扱いDB保存は別フィールド
---
## ソースファイル索引(実装後に更新)
| ファイル | 説明 |
|---|---|
| `backend/apps/pesticide/models.py` | Pesticide, PesticideIngredient, PesticideIngredientLimit, PesticideProductLimit, PesticideCropAlias, SprayEvent, SprayEventResolvedField, SprayEventPesticide |
| `backend/apps/pesticide/serializers.py` | DRF シリアライザ |
| `backend/apps/pesticide/views.py` | ViewSet |
| `backend/apps/pesticide/urls.py` | URL ルーティング |
| `backend/apps/pesticide/management/commands/fetch_pesticide.py` | 農水省スクレイパー |
| `frontend/src/app/pesticide/page.tsx` | 農薬マスタ一覧・散布記録 |
| `frontend/src/app/pesticide/usage/page.tsx` | 使用回数チェック画面 |
| `frontend/src/lib/types.ts` | 型定義Pesticide, SprayEvent 等) |

View File

@@ -0,0 +1,94 @@
# ローカルテスト環境Ubuntu PC
本番同等の環境をローカルで起動し、サーバーのデータで動作確認するための手順。
---
## 構成
| ファイル | 用途 |
|---------|------|
| `docker-compose.local.yml` | 本番用Dockerfileを使用、Traefikなし、ポート直接公開 |
| `deploy_local.sh` | ローカル環境のビルド・起動 |
| `sync_db.sh` | サーバーのDBダンプをローカルに取り込む |
| `.env` | 本番と同じ環境変数git管理外 |
アクセス先:
- フロントエンド: http://localhost:3000
- バックエンドAPI: http://localhost:8000/api/
---
## 初回セットアップ
### 1. .env を作成
```bash
cp .env.production.example .env
# .env に本番と同じ値を設定する
```
### 2. ローカル環境を起動
```bash
bash deploy_local.sh
```
ビルド初回は10〜15分→ 起動 → マイグレーションが自動実行される。
### 3. サーバーのDBを同期
**サーバー側で実行**keinasystemユーザーで:
```bash
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
```
**ローカル側で実行**:
```bash
bash sync_db.sh
```
> `sync_db.sh` はリストア後に自動でマイグレーションを実行する。サーバーより新しいマイグレーションがローカルにある場合でも正しく動作する。
---
## 2回目以降の起動
```bash
# 停止中の場合は起動
docker compose -f docker-compose.local.yml up -d
# 停止
docker compose -f docker-compose.local.yml down
```
コードを変更した場合は再ビルドが必要:
```bash
bash deploy_local.sh
```
---
## DBの再同期
サーバーのデータをローカルに反映したい時。
**サーバー側**keinasystemユーザーで:
```bash
docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
```
**ローカル側**:
```bash
bash sync_db.sh
```
> **注意**: ローカルのDBデータは上書きされる。ローカルで加えた変更は失われる。
---
## 注意事項
- `.env` は gitignore 対象(コミットしない)
- ローカルDBは `postgres_data_local` ボリュームに保存(本番の `postgres_data` とは別)
- `sync_db.sh` は SSH設定 `keinafarm``~/.ssh/config`)を使用

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react'; import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -36,6 +36,14 @@ export default function FertilizerPage() {
const [plans, setPlans] = useState<FertilizationPlan[]>([]); const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [mergeSourcePlan, setMergeSourcePlan] = useState<FertilizationPlan | null>(null);
const [mergeTargets, setMergeTargets] = useState<
{ id: number; name: string; field_count: number; planned_total_bags: string; is_confirmed: boolean }[]
>([]);
const [mergeTargetId, setMergeTargetId] = useState<number | ''>('');
const [mergeLoading, setMergeLoading] = useState(false);
const [mergeSubmitting, setMergeSubmitting] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
localStorage.setItem('fertilizerYear', String(year)); localStorage.setItem('fertilizerYear', String(year));
@@ -83,6 +91,68 @@ export default function FertilizerPage() {
} }
}; };
const openMergeDialog = async (plan: FertilizationPlan) => {
setMergeSourcePlan(plan);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(true);
try {
const res = await api.get(`/fertilizer/plans/${plan.id}/merge_targets/`);
setMergeTargets(res.data);
} catch (e) {
console.error(e);
setMergeError('マージ先候補の読み込みに失敗しました。');
} finally {
setMergeLoading(false);
}
};
const closeMergeDialog = () => {
if (mergeSubmitting) return;
setMergeSourcePlan(null);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(false);
};
const handleMerge = async () => {
if (!mergeSourcePlan || !mergeTargetId) {
setMergeError('マージ先の施肥計画を選択してください。');
return;
}
setMergeSubmitting(true);
setMergeError(null);
try {
await api.post(`/fertilizer/plans/${mergeSourcePlan.id}/merge_into/`, {
target_plan_id: mergeTargetId,
});
closeMergeDialog();
await fetchPlans();
} catch (e: unknown) {
const err = e as {
response?: {
data?: {
error?: string;
conflicts?: { field_name: string; fertilizer_name: string }[];
};
};
};
const conflicts = err.response?.data?.conflicts ?? [];
if (conflicts.length > 0) {
const details = conflicts
.map((conflict) => `${conflict.field_name} × ${conflict.fertilizer_name}`)
.join('、');
setMergeError(`${err.response?.data?.error ?? '競合があるためマージできません。'} ${details}`);
} else {
setMergeError(err.response?.data?.error ?? 'マージに失敗しました。');
}
} finally {
setMergeSubmitting(false);
}
};
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i); const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
return ( return (
@@ -208,6 +278,16 @@ export default function FertilizerPage() {
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
{plan.is_variety_change_plan && (
<button
onClick={() => openMergeDialog(plan)}
className="flex items-center gap-1 rounded border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 hover:bg-emerald-50"
title="既存計画へマージ"
>
<GitMerge className="h-3.5 w-3.5" />
</button>
)}
<button <button
onClick={() => handleDelete(plan.id, plan.name)} onClick={() => handleDelete(plan.id, plan.name)}
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50" className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
@@ -225,6 +305,85 @@ export default function FertilizerPage() {
</div> </div>
)} )}
</div> </div>
{mergeSourcePlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-xl rounded-lg bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">{mergeSourcePlan.name}</p>
</div>
<button onClick={closeMergeDialog} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<p className="text-sm text-gray-600">
×
</p>
{mergeError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{mergeError}
</div>
)}
{mergeLoading ? (
<p className="text-sm text-gray-500">...</p>
) : mergeTargets.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{mergeTargets.map((target) => (
<label
key={target.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 ${
target.is_confirmed ? 'border-gray-200 bg-gray-50 text-gray-400' : 'border-gray-300'
}`}
>
<input
type="radio"
name="merge-target"
value={target.id}
checked={mergeTargetId === target.id}
onChange={() => setMergeTargetId(target.id)}
disabled={target.is_confirmed}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-800">{target.name}</div>
<div className="mt-1 text-xs text-gray-500">
{target.field_count} / {target.planned_total_bags}
{target.is_confirmed ? ' / 散布確定済みのため選択不可' : ''}
</div>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
<button
onClick={closeMergeDialog}
disabled={mergeSubmitting}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
</button>
<button
onClick={handleMerge}
disabled={mergeSubmitting || mergeLoading || !mergeTargetId}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-50"
>
{mergeSubmitting ? 'マージ中...' : 'マージ実行'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -203,6 +203,10 @@ function LeveeWorkPageContent() {
return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id)); return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
}, [form, sortedCandidates]); }, [form, sortedCandidates]);
const selectedAreaTan = useMemo(() => {
return selectedCandidates.reduce((sum, candidate) => sum + Number(candidate.field_area_tan || '0'), 0);
}, [selectedCandidates]);
const handleSort = (nextKey: SortKey) => { const handleSort = (nextKey: SortKey) => {
if (sortKey === nextKey) { if (sortKey === nextKey) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
@@ -335,7 +339,9 @@ function LeveeWorkPageContent() {
> >
<div className="text-sm font-medium text-gray-900">{session.title}</div> <div className="text-sm font-medium text-gray-900">{session.title}</div>
<div className="mt-1 text-sm text-gray-600">{session.date}</div> <div className="mt-1 text-sm text-gray-600">{session.date}</div>
<div className="mt-1 text-xs text-gray-500">{session.item_count}</div> <div className="mt-1 text-xs text-gray-500">
{session.item_count} / {Number(session.total_area_tan).toFixed(2)}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -388,7 +394,9 @@ function LeveeWorkPageContent() {
<div className="mb-3 flex flex-wrap items-center justify-between gap-3"> <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-sm font-medium text-gray-900"></h2> <h2 className="text-sm font-medium text-gray-900"></h2>
<p className="text-xs text-gray-500">{selectedCount} / {candidates.length} </p> <p className="text-xs text-gray-500">
{selectedCount} / {candidates.length} / {selectedAreaTan.toFixed(2)}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button

View File

@@ -1,213 +1,503 @@
'use client'; 'use client';
import { useRouter, usePathname } from 'next/navigation'; import { useEffect, useRef, useState } from 'react';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction, Tractor } from 'lucide-react'; import { usePathname, useRouter } from 'next/navigation';
import {
ChevronDown,
Cloud,
FileText,
History,
KeyRound,
LayoutDashboard,
LogOut,
MapPin,
Menu,
NotebookText,
Package,
PencilLine,
Shield,
Sprout,
Tractor,
Truck,
Upload,
Construction,
Wheat,
X,
type LucideIcon,
} from 'lucide-react';
import { logout } from '@/lib/api'; import { logout } from '@/lib/api';
type NavItem = {
label: string;
href: string;
icon?: LucideIcon;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
icon?: LucideIcon;
items?: NavItem[];
};
const matchesHref = (pathname: string, href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
const navGroups: NavGroup[] = [
{
key: 'home',
label: 'ホーム',
type: 'link',
href: '/dashboard',
icon: LayoutDashboard,
},
{
key: 'planning',
label: '計画',
type: 'group',
icon: Wheat,
items: [
{ label: '作付け計画', href: '/allocation', icon: Wheat },
{
label: '施肥計画',
href: '/fertilizer',
icon: Sprout,
match: (pathname) =>
matchesHref(pathname, '/fertilizer') &&
!matchesHref(pathname, '/fertilizer/spreading') &&
!matchesHref(pathname, '/fertilizer/masters'),
},
{ label: '田植え計画', href: '/rice-transplant', icon: Tractor },
{ label: '運搬計画', href: '/distribution', icon: Truck },
],
},
{
key: 'records',
label: '実績',
type: 'group',
icon: NotebookText,
items: [
{
label: '散布実績',
href: '/fertilizer/spreading',
icon: PencilLine,
},
{ label: '畔塗記録', href: '/levee-work', icon: Construction },
{ label: '作業記録', href: '/workrecords', icon: NotebookText },
],
},
{
key: 'masters',
label: 'マスター',
type: 'group',
icon: Package,
items: [
{ label: '圃場管理', href: '/fields', icon: MapPin },
{
label: '資材マスタ',
href: '/materials/masters',
icon: Package,
},
{
label: '肥料マスタ',
href: '/fertilizer/masters',
icon: Sprout,
},
],
},
{
key: 'support',
label: '帳票・連携',
type: 'group',
icon: FileText,
items: [
{
label: '在庫管理',
href: '/materials',
icon: Package,
match: (pathname) =>
matchesHref(pathname, '/materials') && !matchesHref(pathname, '/materials/masters'),
},
{ label: '帳票出力', href: '/reports', icon: FileText },
{ label: 'データ取込', href: '/import', icon: Upload },
{ label: '気象', href: '/weather', icon: Cloud },
{ label: 'メール履歴', href: '/mail/history', icon: History },
{ label: 'メールルール', href: '/mail/rules', icon: Shield },
],
},
];
const userActions: NavItem[] = [
{ label: 'パスワード変更', href: '/settings/password', icon: KeyRound },
];
export default function Navbar() { export default function Navbar() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const navRef = useRef<HTMLElement>(null);
const [openDesktopGroup, setOpenDesktopGroup] = useState<string | null>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openMobileGroups, setOpenMobileGroups] = useState<string[]>([]);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!navRef.current?.contains(event.target as Node)) {
setOpenDesktopGroup(null);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
useEffect(() => {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
setOpenMobileGroups((prev) => {
const activeKey = getActiveGroupKey(pathname);
if (!activeKey) return prev;
return prev.includes(activeKey) ? prev : [activeKey];
});
}, [pathname]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
}; };
const isActive = (path: string) => pathname === path; const navigateTo = (href: string) => {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
router.push(href);
};
const toggleDesktopGroup = (key: string) => {
setOpenDesktopGroup((prev) => (prev === key ? null : key));
};
const toggleMobileGroup = (key: string) => {
setOpenMobileGroups((prev) =>
prev.includes(key) ? prev.filter((groupKey) => groupKey !== key) : [...prev, key]
);
};
const toggleMobileMenu = () => {
if (!mobileMenuOpen) {
const activeKey = getActiveGroupKey(pathname);
setOpenMobileGroups(activeKey ? [activeKey] : []);
}
setMobileMenuOpen((prev) => !prev);
};
return ( return (
<nav className="bg-white shadow-sm border-b border-gray-200"> <nav ref={navRef} className="border-b border-gray-200 bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex h-16 items-center justify-between">
<div className="flex items-center space-x-8"> <div className="flex items-center gap-4 lg:gap-8">
<button onClick={() => router.push('/dashboard')} className="text-xl font-bold text-green-700 hover:text-green-800 transition-colors"> <button
onClick={() => navigateTo('/dashboard')}
className="text-lg font-bold text-green-700 transition-colors hover:text-green-800 sm:text-xl"
>
KeinaSystem KeinaSystem
</button> </button>
<div className="flex items-center space-x-4">
<button <div className="hidden items-center gap-2 lg:flex">
onClick={() => router.push('/dashboard')} {navGroups.map((group) =>
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${ group.type === 'link' ? (
isActive('/dashboard') <DesktopLinkButton
? 'text-green-700 bg-green-50' key={group.key}
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100' group={group}
}`} pathname={pathname}
> onNavigate={navigateTo}
<LayoutDashboard className="h-4 w-4 mr-2" /> />
) : (
</button> <DesktopGroupButton
<button key={group.key}
onClick={() => router.push('/allocation')} group={group}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${ isOpen={openDesktopGroup === group.key}
isActive('/allocation') pathname={pathname}
? 'text-green-700 bg-green-50' onNavigate={navigateTo}
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100' onToggle={toggleDesktopGroup}
}`} />
> )
<Wheat className="h-4 w-4 mr-2" /> )}
</button>
<button
onClick={() => router.push('/fields')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/fields') || pathname?.startsWith('/fields/')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<MapPin className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/reports')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/reports') || pathname?.startsWith('/reports/')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FileText className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/import')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/import')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Upload className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/mail/history')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/mail/history')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<History className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/mail/rules')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/mail/rules')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Shield className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/weather')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/weather')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<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') && !pathname?.startsWith('/fertilizer/spreading')
? '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>
<button
onClick={() => router.push('/rice-transplant')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/rice-transplant')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Tractor className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/fertilizer/spreading')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/fertilizer/spreading')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<PencilLine className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/distribution')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/distribution')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FlaskConical className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/levee-work')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/levee-work')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Construction className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/materials')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/materials')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Package className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/workrecords')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/workrecords')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<NotebookText className="h-4 w-4 mr-2" />
</button>
</div> </div>
</div> </div>
<div className="flex items-center space-x-1">
<button <div className="hidden items-center gap-1 lg:flex">
onClick={() => router.push('/settings/password')} {userActions.map((item) => (
className="flex items-center px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" <button
title="パスワード変更" key={item.href}
> onClick={() => navigateTo(item.href)}
<KeyRound className="h-4 w-4" /> className={`rounded-md px-3 py-2 text-sm transition-colors ${
</button> isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title={item.label}
>
{item.icon ? <item.icon className="h-4 w-4" /> : item.label}
</button>
))}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors" className="flex items-center rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
> >
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="mr-2 h-4 w-4" />
</button> </button>
</div> </div>
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => navigateTo('/settings/password')}
className={`rounded-md p-2 transition-colors ${
isItemActive(userActions[0], pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title="パスワード変更"
>
<KeyRound className="h-5 w-5" />
</button>
<button
onClick={toggleMobileMenu}
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-expanded={mobileMenuOpen}
aria-label="メニューを開く"
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div> </div>
{mobileMenuOpen && (
<div className="border-t border-gray-200 py-3 lg:hidden">
<div className="space-y-1">
{navGroups.map((group) =>
group.type === 'link' ? (
<MobileLinkButton
key={group.key}
group={group}
pathname={pathname}
onNavigate={navigateTo}
/>
) : (
<MobileGroupButton
key={group.key}
group={group}
isOpen={openMobileGroups.includes(group.key)}
pathname={pathname}
onNavigate={navigateTo}
onToggle={toggleMobileGroup}
/>
)
)}
<button
onClick={handleLogout}
className="mt-3 flex w-full items-center rounded-lg px-3 py-3 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<LogOut className="mr-3 h-4 w-4" />
</button>
</div>
</div>
)}
</div> </div>
</nav> </nav>
); );
} }
function DesktopLinkButton({
group,
pathname,
onNavigate,
}: {
group: NavGroup;
pathname: string;
onNavigate: (href: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<button
onClick={() => group.href && onNavigate(group.href)}
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{group.label}
</button>
);
}
function DesktopGroupButton({
group,
isOpen,
pathname,
onNavigate,
onToggle,
}: {
group: NavGroup;
isOpen: boolean;
pathname: string;
onNavigate: (href: string) => void;
onToggle: (key: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<div className="relative">
<button
onClick={() => onToggle(group.key)}
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
active || isOpen
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
aria-expanded={isOpen}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{group.label}
<ChevronDown className={`ml-2 h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && group.items ? (
<div className="absolute left-0 top-full z-20 mt-2 w-64 rounded-xl border border-gray-200 bg-white p-2 shadow-lg">
{group.items.map((item) => (
<button
key={item.href}
onClick={() => onNavigate(item.href)}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
function MobileLinkButton({
group,
pathname,
onNavigate,
}: {
group: NavGroup;
pathname: string;
onNavigate: (href: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<button
onClick={() => group.href && onNavigate(group.href)}
className={`flex w-full items-center rounded-lg px-3 py-3 text-left text-sm transition-colors ${
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
{group.label}
</button>
);
}
function MobileGroupButton({
group,
isOpen,
pathname,
onNavigate,
onToggle,
}: {
group: NavGroup;
isOpen: boolean;
pathname: string;
onNavigate: (href: string) => void;
onToggle: (key: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<div className="rounded-lg border border-gray-200">
<button
onClick={() => onToggle(group.key)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-3 text-left text-sm transition-colors ${
active || isOpen
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
aria-expanded={isOpen}
>
<span className="flex items-center">
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
{group.label}
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && group.items ? (
<div className="space-y-1 border-t border-gray-200 px-2 py-2">
{group.items.map((item) => (
<button
key={item.href}
onClick={() => onNavigate(item.href)}
className={`flex w-full items-center rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
function isGroupActive(group: NavGroup, pathname: string) {
if (group.type === 'link') {
return group.href ? matchesHref(pathname, group.href) : false;
}
return group.items?.some((item) => isItemActive(item, pathname)) ?? false;
}
function isItemActive(item: NavItem, pathname: string) {
if (item.match) {
return item.match(pathname);
}
return matchesHref(pathname, item.href);
}
function getActiveGroupKey(pathname: string) {
return navGroups.find((group) => isGroupActive(group, pathname))?.key ?? null;
}

View File

@@ -176,6 +176,7 @@ export interface FertilizationPlan {
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied'; spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
is_confirmed: boolean; is_confirmed: boolean;
confirmed_at: string | null; confirmed_at: string | null;
is_variety_change_plan: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -344,6 +345,7 @@ export interface LeveeWorkSession {
notes: string; notes: string;
work_record_id: number | null; work_record_id: number | null;
item_count: number; item_count: number;
total_area_tan: string;
items: LeveeWorkSessionItem[]; items: LeveeWorkSessionItem[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;

File diff suppressed because one or more lines are too long

48
sync_db.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# サーバーのDBをローカルに同期するスクリプト
#
# 事前準備サーバー側でkeinasystemユーザーとして実行:
# docker exec keinasystem_db pg_dump -U keinasystem keinasystem > /tmp/keinasystem_dump.sql
#
# 使用: bash sync_db.sh
set -e
REMOTE_HOST="keinafarm"
LOCAL_DUMP="/tmp/keinasystem_dump.sql"
echo "=== DBSync: サーバー → ローカル ==="
# 1. サーバーからdumpファイルをscpで取得
echo "[1/4] サーバーからダンプファイルを取得..."
scp "$REMOTE_HOST:/tmp/keinasystem_dump.sql" "$LOCAL_DUMP"
echo " → ダンプ取得完了: $LOCAL_DUMP ($(du -sh $LOCAL_DUMP | cut -f1))"
# 2. ローカルのDBコンテナが起動しているか確認
echo "[2/4] ローカルDBコンテナを確認..."
if ! docker compose -f docker-compose.local.yml ps db 2>/dev/null | grep -q "running"; then
echo " → ローカルDBコンテナが起動していません。起動します..."
docker compose -f docker-compose.local.yml up -d db
echo " → DB起動待機中..."
sleep 10
fi
# 3. 既存データをドロップして復元
echo "[3/4] ローカルDBにリストア既存データをリセット..."
# DBを一旦削除して再作成してからリストア
docker compose -f docker-compose.local.yml exec -T db \
psql -U keinasystem -d postgres -c "DROP DATABASE IF EXISTS keinasystem;" --quiet
docker compose -f docker-compose.local.yml exec -T db \
psql -U keinasystem -d postgres -c "CREATE DATABASE keinasystem OWNER keinasystem;" --quiet
cat "$LOCAL_DUMP" | docker compose -f docker-compose.local.yml exec -T db \
psql -U keinasystem -d keinasystem --quiet
echo " → リストア完了"
# クリーンアップ
rm -f "$LOCAL_DUMP"
# 4. マイグレーション(サーバーより新しいマイグレーションを適用)
echo "[4/4] マイグレーション実行..."
docker compose -f docker-compose.local.yml exec backend python manage.py migrate
echo ""
echo "=== 同期完了 ==="

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -0,0 +1,707 @@
# TODO管理機能仕様書案
> 作成日: 2026-04-09
> 最終更新: 2026-04-09
> 対象プロジェクト: `keinasystem`
> 対象 Issue: `akira/keinasystem#17`
> 位置づけ: 実装前ドラフト(レビュー反映版)
---
## 1. 概要
繁忙期の作業を「どれから手を付けるか」の観点で整理するため、Redmine チケットライクな TODO 管理機能を追加する。
本機能は単なるメモではなく、以下の中間レイヤーとして位置付ける。
- 計画
- TODO
- 実績
将来的には、作付け計画を除く各種計画について、`計画 -> TODO -> 実績` の流れに挟める構造を目指す。
ただし MVP では、まず TODO 管理の基本機能、対象圃場の管理、計画との紐づけ、完了時の実績連携導線を整備する。
---
## 2. 背景
現状は施肥計画、田植え計画、運搬計画などの個別機能はあるが、「今日やること」「今週先に処理すべきこと」を横断的に管理する仕組みがない。
そのため、繁忙期には以下の問題が起こりやすい。
- 作業の優先順位が頭の中や紙メモに依存する
- 計画の一部だけを先に実行したい場合に管理しづらい
- 実績入力までの間に「作業待ち」「着手中」の状態を置けない
- 将来追加される作業系機能を共通の入口で扱えない
TODO 管理を導入し、計画単位ではなく「実際に動く作業単位」で優先順位と進行状態を管理できるようにする。
---
## 3. 目的
### 3.1 目指す状態
- 未着手・進行中の作業を優先順で一覧できる
- TODO は計画に紐づくものと、独立したものの両方を扱える
- 計画に紐づく TODO では、計画全体ではなく一部圃場だけを対象にできる
- 完了時に、必要なものは実績系アプリへ連携できる
- 将来増える作業系アプリでも同じ TODO 基盤を使える
### 3.2 今回の対象
- Django 新規アプリ `apps/todos`
- Next.js 画面 `frontend/src/app/todos`
- REST API `/api/todos/`
- 計画画面からの TODO 生成導線
### 3.3 今回やらないこと
- 期日通知、リマインダー、メール通知
- 複数ユーザー割り当て
- コメント、添付ファイル
- 工数見積、実績時間記録
- 完全な汎用ワークフローエンジン化
---
## 4. 基本方針
### 4.1 TODO の位置づけ
TODO は「作業指示」兼「実行待ちキュー」として扱う。
- 計画は年間またはまとまり単位の設計情報
- TODO は実際に動く単位の作業
- 実績は実際に完了した事実
### 4.2 計画との関係
- 1 計画に対して複数 TODO を紐づけられる
- 1 TODO は複数計画を参照できる
- ただし TODO の実際の対象圃場は TODO 側で明示管理する
- 計画に含まれる圃場の一部だけを TODO 対象にすることを許可する
### 4.3 実績との関係
- TODO 完了時に、実績アプリを持つ作業は実績生成の入口にする
- ただし、すべての TODO が実績アプリを持つとは限らない
- 計画なし TODO、実績なし TODO も許容する
### 4.4 圃場グループの扱い
圃場グループは独立モデル化しない。
既存の `Field.group_name` を参照用の属性として扱うにとどめ、TODO の正式な対象管理は圃場単位で保持する。
理由:
- 現状のデータモデルに独立したグループモデルが存在しない
- TODO 完了後に履歴の再現性を保つには、最終的に対象圃場を確定保持した方が安全
---
## 5. 機能スコープ
### 5.1 IN
- TODO の作成、編集、削除
- ステータス管理
- 優先順位管理
- 圃場単位の対象紐づけ
- 作物、品種の補助的な分類紐づけ
- 計画との紐づけ
- 計画画面から TODO を生成
- 完了済み、キャンセル済みの表示切り替え
- 期日の強調表示
- 並び替え API
### 5.2 OUT
- 通知
- 担当者管理
- 承認フロー
- 複数段階ステータス
- 実績アプリ未実装領域の詳細実績入力 UI
---
## 6. 用語整理
| 用語 | 意味 |
|---|---|
| TODO | 実際に着手・進行・完了する作業単位 |
| 計画リンク | TODO が参照する施肥計画、田植え計画など |
| 対象圃場 | その TODO で実際に作業対象となる圃場 |
| 実績連携 | TODO 完了時に各実績アプリへ情報を渡すこと |
---
## 7. データモデル方針
### 7.1 Todo
TODO 本体。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| year | integer | ✓ | 年度 |
| title | varchar(200) | ✓ | タイトル |
| description | text | | 説明 |
| status | enum | ✓ | `todo / doing / done / canceled` |
| priority | integer | ✓ | 小さいほど上位 |
| due_date | date | | 期日 |
| work_type | enum | ✓ | 作業種別 |
| should_link_record | boolean | ✓ | 完了時に実績連携導線を有効にするか |
| completed_at | datetime | | 完了日時 |
| canceled_at | datetime | | キャンセル日時 |
| created_at | datetime | ✓ | |
| updated_at | datetime | ✓ | |
### 7.1.1 ステータス
- `todo`: 未着手
- `doing`: 進行中
- `done`: 完了
- `canceled`: キャンセル
### 7.1.2 並び順
- 基本は FILO とする
- 新規作成時は最上位へ入る
- `priority` は 1000 刻みの整数で保存する
- 初回作成時は最上位 TODO の `priority - 1000` を新規 TODO に割り当てる
- 一覧では `priority` 昇順で表示する
- ユーザーが並び替えた後は、表示順に 1000, 2000, 3000... と振り直して保存する
- 既存レコードの一括インクリメントや小数 priority は採用しない
- 完了、キャンセル済みも `priority` は保持する
- 一覧のデフォルト表示は `todo / doing` のみを `priority` 昇順で表示する
補足:
- 1000 刻みは API の中間挿入余地ではなく、再採番時の可読性のために採用する
- 並び順変更は常に表示対象全体を受け取って再採番する前提とする
### 7.1.3 作業種別
作業種別は「計画に対応するもの」と「計画に対応しないもの」の両方を含める。
初期案:
- `general`: 一般
- `fertilization`: 施肥
- `rice_transplant`: 田植え
- `delivery`: 運搬
- `levee_work`: 畔塗
- `pesticide`: 防除
- `other_recorded`: 計画非紐づき実績系
補足:
- 実装時点で将来の全計画種別を確定できない場合は、MVP では現行アプリに対応する種別を先行定義する
- `general` はどれにも当てはまらない作業用に必須
### 7.2 TodoTargetField
TODO が実際に対象とする圃場。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | CASCADE |
| field | FK(fields.Field) | ✓ | PROTECT |
| field_name_snapshot | varchar(100) | ✓ | 保存時点の圃場名 |
| group_name_snapshot | varchar(50) | | 保存時点の group_name |
| created_at | datetime | ✓ | |
- `unique_together = ['todo', 'field']`
方針:
- TODO の対象管理は最終的に圃場単位で保持する
- グループ、作物、品種から一括選択する UI は許可する
- ただし保存時は対象圃場へ展開して保持する
### 7.3 TodoCrop / TodoVariety
TODO の分類補助用。
#### TodoCrop
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | CASCADE |
| crop | FK(plans.Crop) | ✓ | PROTECT |
| created_at | datetime | ✓ | |
- `unique_together = ['todo', 'crop']`
#### TodoVariety
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | CASCADE |
| variety | FK(plans.Variety) | ✓ | PROTECT |
| created_at | datetime | ✓ | |
- `unique_together = ['todo', 'variety']`
注意:
- 対象圃場の実体は `TodoTargetField` を正とする
- `Crop``Variety` だけ紐づいていて圃場が 0 件の TODO は許可する
- これにより、圃場未確定の準備作業も登録できる
### 7.4 TodoPlanLink
TODO と既存計画との紐づけ。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | CASCADE |
| plan_type | enum | ✓ | 計画種別 |
| fertilization_plan | FK | | 施肥計画 |
| rice_transplant_plan | FK | | 田植え計画 |
| delivery_plan | FK | | 運搬計画 |
| created_at | datetime | ✓ | |
方針:
- 1 行に 1 種別のリンクだけを保持する
- `plan_type` に応じて対応する FK だけを埋める
- MVP は汎用 `GenericForeignKey` を使わず、明示 FK を優先する
- 理由は API と serializer を単純に保ちやすいため
初期対象:
- 施肥計画 `FertilizationPlan`
- 田植え計画 `RiceTransplantPlan`
- 運搬計画 `DeliveryPlan`
- 畔塗 `levee_work` は MVP では「計画リンクなしで持てる work_type」として扱う
- 将来、畔塗に計画モデルが導入された時点で `TodoPlanLink` に追加する
補足:
- 作付け計画 `Plan` は「年内の計画情報」であり、TODO 生成元としては必須ではない
- 当面は Issue 回答に合わせ、`作付け計画以外のすべての計画` を TODO の対象候補とする
### 7.5 TodoCompletionLink
完了時の実績連携先を記録する索引。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| id | bigint | ✓ | PK |
| todo | FK(Todo) | ✓ | TODO |
| record_type | enum | ✓ | 実績種別 |
| work_record | FK(workrecords.WorkRecord) | | 共通索引 |
| spreading_session | FK(fertilizer.SpreadingSession) | | 施肥実績 |
| rice_transplant_record_id | 将来 | | 田植え実績 |
| created_at | datetime | ✓ | |
方針:
- 完了時に何へ連携したかを TODO 側から追えるようにする
- `todo` は OneToOne に固定せず FK とする
- 理由は 1 TODO から複数実績へ分割される可能性を残すため
- 実績アプリが未実装の種別は空でよい
- 将来の田植え実績導入時に拡張できる形にする
---
## 8. API 仕様案
### 8.1 一覧
- `GET /api/todos/`
主な query:
- `status=todo,doing`
- `include_closed=true|false`
- `work_type=...`
- `due=overdue|today|upcoming`
- `year=2026`
デフォルト:
- `include_closed=false`
- `status=todo,doing`
- `priority` 昇順
### 8.2 詳細取得
- `GET /api/todos/{id}/`
返却内容:
- TODO 本体
- 対象圃場
- 作物、品種
- 計画リンク
- 完了連携状況
### 8.3 作成
- `POST /api/todos/`
作成 payload 例:
```json
{
"title": "西田エリアの追肥",
"description": "週内に先行実施",
"status": "todo",
"year": 2026,
"due_date": "2026-04-12",
"work_type": "fertilization",
"should_link_record": true,
"field_ids": [12, 18, 21],
"crop_ids": [1],
"variety_ids": [4],
"plan_links": [
{"plan_type": "fertilization", "plan_id": 8}
]
}
```
`plan_links` の吸収方針:
- API 入力は `plan_type + plan_id` の組で受ける
- Serializer で `plan_type` を見て対応 FK へ変換する
- 例:
- `fertilization` -> `fertilization_plan_id`
- `rice_transplant` -> `rice_transplant_plan_id`
- `delivery` -> `delivery_plan_id`
- DB 返却時は、フロントエンド向けに再び `plan_type + plan_id + plan_label` の形へ正規化して返す
### 8.4 更新
- `PATCH /api/todos/{id}/`
更新可能項目:
- タイトル
- 説明
- ステータス
- 期日
- 作業種別
- 実績連携フラグ
- 対象圃場
- 分類
- 計画リンク
### 8.5 削除
- `DELETE /api/todos/{id}/`
ルール案:
- 連携済み実績がある TODO は物理削除ではなく制限をかける案を優先
- MVP ではまず `done` かつ実績連携済み TODO の削除可否を要確認とする
### 8.6 並び替え
- `PATCH /api/todos/reorder/`
payload 例:
```json
{
"items": [
{"id": 31, "priority": 1000},
{"id": 27, "priority": 2000},
{"id": 42, "priority": 3000}
]
}
```
方針:
- 一括更新で保存する
- DnD が難しい場合も、矢印移動 UI から同 API を呼ぶ
### 8.7 計画から TODO 生成
- `POST /api/todos/from-plan/`
payload 例:
```json
{
"plan_type": "fertilization",
"plan_id": 8,
"title": "2026春肥の散布",
"field_ids": [12, 18],
"due_date": "2026-04-15",
"should_link_record": true
}
```
生成ルール:
- 既存計画をリンクする
- `field_ids` 未指定時は計画内の全圃場を初期対象にする
- `work_type``plan_type` から自動補完する
- タイトルは自動生成可能にする
### 8.8 完了処理
- `POST /api/todos/{id}/complete/`
方針:
- `status=done` にする専用入口を用意する
- `should_link_record=true` かつ対応実績アプリがある場合、関連画面へ遷移するための情報を返す
- MVP で自動実績作成まで行うか、完了導線のみ返すかは実装時に選べるようにする
---
## 9. UI 仕様案
### 9.1 一覧画面 `/todos`
表示内容:
- 未着手、進行中 TODO を優先表示
- タイトル
- ステータス
- 期日
- 作業種別
- 対象圃場数
- 紐づき計画
操作:
- 新規作成
- ステータス変更
- 並び替え
- 完了済み、キャンセル済み表示切り替え
- 絞り込み
視覚表現:
- 期限超過は赤系
- 当日期限は強調
- 進行中は目立つバッジ表示
### 9.2 詳細画面 `/todos/{id}`
表示・編集項目:
- タイトル
- 説明
- ステータス
- 期日
- 作業種別
- 実績連携フラグ
- 対象圃場
- 分類作物、分類品種
- 計画リンク
下部表示:
- 実績連携先
- 完了日時
- 更新日時
### 9.3 作成導線
MVP では少なくとも以下の 2 導線を持つ。
1. TODO 一覧から新規作成
2. 計画詳細または一覧から TODO 生成
### 9.4 計画画面からの導線
対象候補:
- 施肥計画
- 田植え計画
- 運搬計画
ボタン例:
- `TODOを作成`
- `この計画からTODO生成`
初期値:
- タイトル
- 作業種別
- 対象圃場候補
- `should_link_record`
---
## 10. 実績連携の考え方
### 10.1 基本原則
- TODO は実績そのものではない
- ただし、実績入力の起点にはなる
- すべての TODO が実績へ行くわけではない
### 10.2 施肥
将来像:
1. 施肥計画を作る
2. TODO を生成する
3. TODO を実施する
4. 完了時に施肥実績へつなぐ
考え方:
- 従来の `施肥計画 -> 施肥実績` に対し、間に TODO が入れるようにする
- TODO 完了時は `SpreadingSession` 作成導線へつなぐ
- 対象圃場は TODO の `TodoTargetField` を初期値として渡す
### 10.3 田植え
田植え実績アプリは今後実装予定であるため、今回の TODO 側では以下を前提にする。
- `rice_transplant` 種別の TODO を持てる
- 完了時に将来の田植え実績へ接続できるよう索引設計を残す
- MVP 時点では「完了済みだが実績アプリ未接続」の状態も許容する
### 10.4 実績アプリが無い作業
- `general` など、実績アプリに紐づかない TODO を許容する
- その場合は `status=done` のみで完了とする
---
## 11. バリデーション方針
- `done` に遷移したら `completed_at` を自動設定する
- `canceled` に遷移したら `canceled_at` を自動設定する
- `done` から `todo` または `doing` への差し戻しは MVP では許可する
- 差し戻し時も `completed_at` はクリアせず履歴値として保持する
- `plan_links` に紐づく計画の年度と TODO の利用年度が必要なら将来追加する
- `field_ids` が計画外圃場を含む場合は、`plan_links` が 1 件以上ある場合のみエラーにする
- 複数 `plan_links` がある場合は、それぞれの計画に対して対象圃場整合性を検証する
- `should_link_record=true` でも、対応実績アプリが無い場合は保存を許可する
- `TodoTargetField.field``PROTECT` を採用する
- 理由は、過去 TODO の対象圃場履歴を崩さないことを優先するため
### 11.1 レビュー反映済み判断
- `done -> todo/doing` の差し戻しは許可する
- 差し戻し後も `completed_at` は監査用の履歴値として保持する
- `TodoTargetField.field` は運用上の削除容易性より履歴保全を優先し、`PROTECT` を維持する
- 実績連携フラグ名は `should_link_record` で確定する
---
## 12. 実装方針
### 12.1 Backend
- `apps/todos/models.py`
- `apps/todos/admin.py`
- `apps/todos/serializers.py`
- `apps/todos/views.py`
- `apps/todos/urls.py`
- `apps/todos/migrations/`
- `keinasystem/settings.py` へ app 追加
- `keinasystem/urls.py``/api/todos/` 追加
### 12.2 Frontend
- `frontend/src/app/todos/page.tsx`
- `frontend/src/app/todos/[id]/page.tsx`
- `frontend/src/app/todos/new/page.tsx`
- 必要に応じて `_components` 配下に分離
- ナビゲーションへ TODO 追加
### 12.3 実装順
1. モデル、admin、serializer、migration の作成
2. TODO 一覧と CRUD API
3. TODO 一覧と詳細 UI
4. 並び替え API と UI
5. 計画から TODO 生成
6. 完了時の実績連携導線
7. `makemigrations``migrate` を実行
---
## 13. テスト観点
- TODO を新規作成できる
- 対象圃場を複数紐づけできる
- 計画の一部圃場だけを対象にできる
- 完了済み、キャンセル済みの表示切り替えができる
- 並び替え後に順番が保持される
- 計画画面から TODO を生成できる
- 実績アプリ未接続の TODO でも完了できる
- 実績連携済み TODO の挙動が壊れない
---
## 14. 未確定事項
### 14.1 work_type enum の最終一覧
今回の回答で方針は見えたが、初回実装でどこまで列挙するかは確定していない。
候補:
- 一般
- 施肥
- 田植え
- 運搬
- 畔塗
- 防除
- 計画非紐づき実績系
### 14.2 完了時の自動生成レベル
MVP で以下のどこまでやるかは実装前に決める。
- A. 完了ステータス変更のみ
- B. 実績入力画面への導線生成
- C. TODO 情報を使った実績レコード仮生成
### 14.3 削除ポリシー
実績連携後の TODO をどう扱うか。
案:
- 物理削除禁止
- 論理削除
- 参照整合性チェック付き物理削除
### 14.4 work_type と計画種別の追加ルール
MVP では以下を前提とする。
- work_type は先に定義する
- plan_link は実在する計画モデルだけを持つ
- work_type が存在しても、対応する計画 FK が未実装のことはあり得る
将来、新しい計画機能が増えたときは以下を同時に更新する。
- `Todo.work_type` choices
- `TodoPlanLink.plan_type`
- 対応 FK
- 計画から TODO 生成 API
---
## 15. 提案する MVP 決定案
実装着手しやすさを優先し、MVP では以下を採用することを提案する。
- TODO は `year` を持つ
- 対象管理は `TodoTargetField` を正とする
- `work_type``general / fertilization / rice_transplant / delivery / levee_work / pesticide` を初期採用する
- 計画リンクは明示 FK 方式で開始する
- 実績連携フラグ名は `should_link_record` を採用する
- 完了時はまず「実績入力画面への導線生成」を採用し、自動実績作成は後続検討とする
- 並び替えは API 先行、UI は DnD 優先、難しければ矢印移動で代替する

View File

@@ -0,0 +1,528 @@
# ナビゲーション再編仕様書
> 作成日: 2026-04-07
> 対象: `frontend/src/components/Navbar.tsx`
> 方針: 第一候補「上段5分類 + ドロップダウン」
---
## 0. 背景
現状のグローバルナビゲーションは、機能追加のたびに横並びのボタンを増やしており、以下の問題が出ている。
- 上位階層の導線が多すぎて、目的の画面を探しにくい
- 「計画」「実績」「設定」「補助機能」が同じ粒度で並んでいる
- メール関連のように、単独トップに置くほどではない機能が場所を取りやすい
- 今後も機能追加が続くと、横幅不足と認知負荷の両方が悪化する
このため、トップナビは「日常的に使う業務カテゴリ」だけを見せ、個別画面はドロップダウン配下へ整理する。
---
## 1. 目的
### 1-1. 目指す状態
- 1階層目では「何をしたいか」で探せる
- 似た役割の画面を同じカテゴリに集約する
- 画面数が増えても、トップレベルの見た目を増やしすぎない
- PC とスマホで同じ情報設計を維持する
### 1-2. 今回の対象
- 共通ヘッダー内のグローバルナビゲーション再編
- メニュー分類、ラベル、並び順、開閉仕様の定義
- 各画面がどのカテゴリに属するかの明確化
### 1-3. 今回やらないこと
- 各業務画面そのものの UI 改修
- 権限別メニュー出し分け
- お気に入り機能、ピン留め機能
- ナビゲーションと連動したダッシュボード内容の刷新
### 1-4. 関連 Issue との役割分担
- Issue `#13 メニューがごちゃごちゃしてきたので、整理する` は、背景、論点、判断理由を残す親議論として扱う
- 本仕様書は、その議論を踏まえた実装向けの決定事項をまとめる文書として扱う
- 後から判断理由を確認したい場合は Issue `#13` を参照する
---
## 2. 基本方針
### 2-1. トップレベル構成
トップナビでは以下の 5 項目のみを常時表示する。
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
右端には従来どおりユーザー操作を置く。
- パスワード変更
- ログアウト
### 2-2. 設計ルール
- 毎日使う業務カテゴリだけをトップに置く
- 個別機能名ではなく、業務単位で束ねる
- 設定、履歴、通知、補助系は単独トップにしない
- 同じ業務の前後工程は可能な限り同じカテゴリに寄せる
---
## 3. 情報設計
### 3-1. カテゴリ構成
#### ホーム
- ダッシュボード
#### 計画
- 作付け計画
- 施肥計画
- 田植え計画
- 運搬計画
#### 実績
- 散布実績
- 畔塗記録
- 作業記録
#### マスター
- 圃場管理
- 作物
- 品種
- 資材マスタ
- 肥料マスタ
#### 帳票・連携
- 在庫管理
- 帳票出力
- データ取込
- 気象
- メール
### 3-2. メールの扱い
メール関連はトップ階層に個別表示しない。
`帳票・連携 > メール` の中にまとめる。
内訳:
- メール履歴
- メールルール
### 3-3. 設定の扱い
現状はパスワード変更のみのため、独立カテゴリにはしない。
初期実装では右上アイコンからのパスワード変更導線を維持し、設定系機能が増えた場合に別途 `設定` グループ化を検討する。
---
## 4. 画面とメニューの対応
### 4-1. 現在の主要画面の所属
| カテゴリ | ラベル | パス |
|---|---|---|
| ホーム | ダッシュボード | `/dashboard` |
| 計画 | 作付け計画 | `/allocation` |
| 計画 | 施肥計画 | `/fertilizer` |
| 計画 | 田植え計画 | `/rice-transplant` |
| 計画 | 運搬計画 | `/distribution` |
| 実績 | 散布実績 | `/fertilizer/spreading` |
| 実績 | 畔塗記録 | `/levee-work` |
| 実績 | 作業記録 | `/workrecords` |
| マスター | 圃場管理 | `/fields` |
| マスター | 作物 | `未実装: allocation 内管理を独立予定` |
| マスター | 品種 | `未実装: allocation 内管理を独立予定` |
| マスター | 資材マスタ | `/materials/masters` |
| マスター | 肥料マスタ | `/fertilizer/masters` |
| 帳票・連携 | 在庫管理 | `/materials` |
| 帳票・連携 | 帳票出力 | `/reports` |
| 帳票・連携 | データ取込 | `/import` |
| 帳票・連携 | 気象 | `/weather` |
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
| 帳票・連携 > メール | メールルール | `/mail/rules` |
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
### 4-2. アクティブ表示ルール
- その画面自身、またはその配下詳細画面にいる場合、所属カテゴリをアクティブにする
- ドロップダウン内の該当項目も個別にアクティブ表示する
- パス接頭辞だけで判定するとカテゴリが衝突する画面があるため、より具体的なパスを優先して判定する
- 特に `施肥計画``散布実績` はともに `/fertilizer` 配下を使うため、`/fertilizer/spreading` を先に判定し、`計画` 側から明示的に除外する
- 同様に `在庫管理``資材マスタ` はともに `/materials` 配下を使うため、`/materials/masters` を先に判定し、`帳票・連携` 側の `在庫管理` から明示的に除外する
- 例:
- `/fertilizer``計画` をアクティブ
- `/fertilizer/new``計画` をアクティブ
- `/fertilizer/[id]` および `/fertilizer/[id]/edit``計画` をアクティブ
- `/fertilizer/spreading``実績` をアクティブ
- `/fertilizer/spreading?...``実績` をアクティブ
- `/materials``帳票・連携` をアクティブ
- `/materials/masters``マスター` をアクティブ
- 例:
- `/fields/123` の場合は `マスター` がアクティブ
- `/fertilizer/10/edit` の場合は `計画` がアクティブ
- `/mail/history` の場合は `帳票・連携``メール履歴` がアクティブ
---
## 5. PC 表示仕様
### 5-1. レイアウト
PC ではヘッダーを 3 ブロック構成とする。
1. 左: ブランド名 `KeinaSystem`
2. 中央: トップメニュー 5 項目
3. 右: パスワード変更、ログアウト
### 5-2. トップメニューの見せ方
- `ホーム` は単独リンク
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン付きメニュー
- ラベルの横に開閉アイコンを表示する
- 開いたメニューは白背景のパネルとして表示する
### 5-3. 開閉ルール
- クリックで開閉
- 開いている他メニューがある場合は、それを閉じてから新しいメニューを開く
- メニュー外クリックで閉じる
- `Esc` キーで閉じる
- キーボード操作は初期実装ではブラウザ標準の `Tab` 移動を基本とする
- 矢印キーによるドロップダウン項目間移動は Phase 1 の必須要件には含めない
- 項目クリック後は遷移して閉じる
### 5-4. ドロップダウンの表示内容
各項目は以下の順で並べる。
#### 計画
1. 作付け計画
2. 施肥計画
3. 田植え計画
4. 運搬計画
#### 実績
1. 散布実績
2. 畔塗記録
3. 作業記録
#### マスター
1. 圃場管理
2. 作物
3. 品種
4. 資材マスタ
5. 肥料マスタ
#### 帳票・連携
1. 在庫管理
2. 帳票出力
3. データ取込
4. 気象
5. メール
`メール` は 2 段構造にする方法と、直接展開せず一覧モーダル風に見せる方法があるが、初期実装ではシンプルさを優先し、`帳票・連携` ドロップダウン内に個別リンクを直接置く。
初期実装の並び:
1. 在庫管理
2. 帳票出力
3. データ取込
4. 気象
5. メール履歴
6. メールルール
なお情報設計上の名称としては `メール` を維持し、将来的に機能が増えた時点で再度サブグループ化する。
---
## 6. スマホ表示仕様
### 6-1. 基本方針
スマホではハンバーガーメニューを採用する。
PC と同じカテゴリ構成を維持し、見た目だけ縦並びにする。
### 6-2. 表示ルール
- 初期状態ではロゴ、メニューボタン、ログアウト系導線のみ表示
- メニューボタン押下で全画面または右スライドのメニューを開く
- カテゴリはアコーディオン形式で開閉する
### 6-3. 並び順
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
6. パスワード変更
7. ログアウト
### 6-4. 開閉ルール
- `ホーム` は単独リンクとし、タップ時はそのまま `/dashboard` へ遷移する
- カテゴリ見出しタップで開閉
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
- 項目タップ後はメニューを閉じて画面遷移する
---
## 7. ラベル方針
### 7-1. トップ階層ラベル
- 短く、役割が伝わる言葉を使う
- 詳細機能名は 2 階層目へ寄せる
### 7-2. 用語ルール
- `ホーム` は利用者に最も分かりやすいため維持
- `計画` は作付け、施肥、田植え、運搬を含む包括名として使用
- `実績` は記録系業務のまとめ先とする
- `マスター` は日々の業務を支える基礎データ管理の置き場とする
- `帳票・連携` は出力、取込、通知、補助参照のまとめ先とする
将来 `帳票・連携` に機能が増えすぎた場合は、次の再編を検討する。
- `帳票`
- `連携`
- `通知`
---
## 8. アイコン方針
### 8-1. トップ階層
トップ階層には必要最低限のアイコンのみ使用する。
- ホーム: 家またはダッシュボード系
- 計画: 作物または計画系
- 実績: チェック、記録系
- マスター: 設定、リスト、データベース系
- 帳票・連携: ファイル、送受信、クラウド系
ただし、文字認識を優先し、アイコンは補助扱いとする。
### 8-2. ドロップダウン内
現行アイコンを流用してよいが、すべてに付ける必要はない。
視認性よりも一覧性を優先し、テキスト中心でも可。
---
## 9. 操作性・アクセシビリティ要件
- キーボードでトップメニューにフォーカス移動できること
- `Enter` または `Space` でドロップダウンを開閉できること
- ドロップダウン展開後、各項目へ `Tab` で到達できること
- `Esc` で閉じられること
- 矢印キーによる項目間移動は初期実装の必須要件には含めず、将来のアクセシビリティ強化項目として扱う
- 現在位置が視覚的に分かること
- タップ領域は十分に確保すること
- スマホで誤タップしにくい行間と余白を確保すること
---
## 10. 実装方針
### 10-1. コンポーネント構成案
`Navbar.tsx` を以下の責務に分ける。
- ブランド表示
- トップメニュー定義
- ドロップダウン表示
- モバイルメニュー表示
- 右端ユーザー操作
必要に応じて次のような補助構成へ分割してよい。
- `navGroups` 定数
- `DesktopNav`
- `MobileNav`
### 10-2. メニュー定義データ化
個別ボタンの直書きはやめ、カテゴリ配列で管理する。
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
方針:
- グループ構成そのものが定義から読み取れることを優先する
- 通常ケースは `href` ベースで扱う
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
想定イメージ:
```ts
type NavItem = {
label: string;
href: string;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
items?: NavItem[];
};
const navGroups = [
{
key: 'home',
label: 'ホーム',
type: 'link',
href: '/dashboard',
},
{
key: 'planning',
label: '計画',
type: 'group',
items: [
{ label: '作付け計画', href: '/allocation' },
{ label: '施肥計画', href: '/fertilizer' },
{ label: '田植え計画', href: '/rice-transplant' },
{ label: '運搬計画', href: '/distribution' },
],
},
];
```
### 10-3. アクティブ判定
アクティブ判定は、現在の `pathname` と各項目の対応パターンで管理する。
基本原則:
- URL はリソース・機能識別子として安定性を優先し、メニュー階層とは分離して扱う
- メニュー再編のたびに URL を変更しない
- アクティブ判定はナビ定義側のルールで吸収する
- ただし、全件をルーターのように再定義するのではなく、通常ケースは `href` ベース、衝突ケースだけ `match` を使う
補足:
- Next.js App Router の Route Groups は、URL を変えずにコード構造を整理する手段としては有効
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
パス接頭辞衝突が起きる組み合わせは、具体パス優先で次のように扱う。
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|---|---|---|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
通常判定の例:
- `/fertilizer`
- `/fertilizer/new`
- `/fertilizer/[id]/edit`
はすべて `施肥計画` 所属として扱う。
- `/materials`
- `/materials?tab=...`
`在庫管理` 所属として扱う。
実装上は、上記の衝突ケースのみ `NavItem.match` を使う想定とする。
---
## 11. 段階導入案
### Phase 1
- PC ナビを 5 分類へ再編
- `作物` `品種` はマスター体系に含めるが、Phase 1 ではメニューに表示しない
- Phase 1 の `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみとする
- `作物` `品種` は未実装項目として仕様上は位置づけ、独立画面が用意できる Phase 2 でメニュー表示を開始する
- モバイルも同じ情報設計へ変更
### Phase 2
- `作物管理` `品種管理` を独立画面として追加
- `帳票・連携` 内の `メール` をサブグループ化
- よく使う画面の履歴や最近使った機能を補助表示
### Phase 3
- 将来マルチユーザー化した場合の再設計検討
- 権限や担当業務ごとの表示最適化の要否整理
- 単独利用を前提とする間は、Phase 3 は実施対象外とする
---
## 12. 受け入れ条件
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
- 各画面でアクティブ状態が期待通りに表示されること
- PC とスマホで同じカテゴリ構成になっていること
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
---
## 13. 想定される懸念と対応
### 13-1. 「マスター」に含める範囲
現時点では、`圃場管理` `作物` `品種` `資材マスタ` `肥料マスタ``マスター` として集約する。
日々の入力対象ではなく、業務の前提データを整える画面群としてまとめるのが自然である。
補足:
- `圃場管理` は圃場マスタとして独立性が高い
- `作物``品種` も本来マスター管理であり、現状は allocation 画面内で扱っているだけで、メニュー上は独立させる前提で考える
- `資材マスタ``肥料マスタ` はすでに独立ページがあり、`マスター` 配下に置くのが自然である
- 将来、地図、圃場グループ、所有者管理などが増えた場合は、`圃場マスタ` の再独立も検討する
### 13-2. 「帳票・連携」が少し広い
現時点では `在庫管理` `帳票出力` `データ取込` `気象` `メール` をまとめる。
完全に同じ性質ではないが、いずれも補助業務、参照、連携、出力に近い機能であり、トップ階層を増やしすぎないために同居させる。
### 13-3. 「データ取込」は日常操作ではない
`データ取込` は日常的に何度も使う画面ではなく、年度切替時や初期設定時に使う補助導線である。
そのためトップレベル常設には置かず、`帳票・連携` 配下に置く判断は妥当とする。
ただし今後、取込対象や運用頻度が増えた場合は、`設定` または `運用` 系カテゴリへの移設も検討対象とする。
### 13-4. 既存ユーザーが場所を見失う
初期導入時は以下を行う。
- 並び順をできるだけ業務の流れに合わせる
- ドロップダウン内で既存画面名は変更しない
- ダッシュボード上に主要導線を残す
---
## 14. 結論
現行の 1 画面 1 ボタン方式は、今後の機能追加に対して拡張性が低い。
そのため、トップナビは `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類へ再編し、個別画面はドロップダウン配下に置く。
この構成により、利用者は「画面名」ではなく「やりたい業務」から機能を探せるようになり、将来の機能追加にも耐えやすくなる。