# Keina System - Gemini向け実装指示書 ## 📋 このドキュメントについて これは、農業生産者向けの作付け計画管理システム「Keina System」の実装に必要な全情報を統合したドキュメントです。 **実装エージェント(Gemini/OpenCode)は、以下の順序でドキュメントを読み、理解してから実装を開始してください。** --- ## 🎯 システムの全体像 ### 目的 年間の作付け計画を管理し、役場への申請書類(水稲共済細目書・中山間地域等直接支払交付金)を自動生成するシステム。 ### ユーザー - 65歳の農家(元プログラマー) - シングルユーザー(マルチテナント不要) - PCで登録・編集、スマホで参照 ### 主要機能(Phase 1 / MVP) 1. 作付け計画の一覧表示・編集 2. 水稲共済細目書のCSV出力 3. 中山間交付金申請書のCSV出力 4. 前年度作付けのコピー機能 5. 圃場情報のスマホ参照 ### 技術スタック - **バックエンド**: Django 5.0 + Django REST Framework + GeoDjango - **フロントエンド**: Next.js 14 (App Router) + Tailwind CSS - **データベース**: PostgreSQL 16 + PostGIS 3.4 - **インフラ**: Docker Compose --- ## 📚 必読ドキュメント(この順に読むこと) 実装前に、以下のドキュメントを**必ず全て読んでください**。各ドキュメントには実装に必要な重要な情報が含まれています。 ### 1. プロダクトビジョン.md - システムの目的、ユーザー像、成功の定義 - なぜこのシステムを作るのか、何を目指すのか - **👉 読むべき理由**: ゴールを理解しないと、適切な実装判断ができない ### 2. ユーザーストーリー.md - 具体的な利用シーン(優先度付き) - 受け入れ基準、実装すべき機能の詳細 - **👉 読むべき理由**: 何を作るべきか、どこまで作るべきかが明確になる ### 3. データ仕様書.md - 3種類のデータ(実圃場、共済マスタ、中山間マスタ)の関係 - 紐付けロジック(M:1関係)の詳細 - 申請書CSV出力のアルゴリズム - **👉 読むべき理由**: データモデルの設計ミスは後から修正困難 ### 4. 画面設計書.md - 全画面のワイヤーフレーム - UI共通仕様(カラー、フォント、レスポンシブ) - **👉 読むべき理由**: UIの実装イメージが具体的につかめる ### 5. 実装優先順位.md - 10日間の実装スケジュール - 技術スタックの詳細、潜在的な課題と対策 - **👉 読むべき理由**: 何から作るか、どう作るかの指針 --- ## 🚀 実装の進め方(ステップバイステップ) ### Step 0: 事前準備(必須) 1. 上記5つのドキュメントを**全て読む** 2. 不明点があれば、実装開始前に質問する 3. 実データ(`吉田農地台帳.ods`, `水稲共済細目用.ods`, `中山間.ods`)を確認 ### Step 1: 環境構築(Day 1) ```bash # プロジェクト構造 keinasystem/ ├── docker-compose.yml ├── backend/ │ ├── Dockerfile │ ├── requirements.txt │ ├── manage.py │ └── keinasystem/ │ ├── settings.py │ ├── urls.py │ └── apps/ │ ├── fields/ # 圃場管理 │ ├── plans/ # 作付け計画 │ └── reports/ # 申請書出力 └── frontend/ ├── Dockerfile ├── package.json ├── app/ │ ├── login/ │ ├── dashboard/ │ ├── allocation/ # 作付け計画編集 │ └── reports/ # 申請書ダウンロード └── lib/ ├── api.ts # API呼び出し └── types.ts # 型定義 ``` #### docker-compose.yml ```yaml version: '3.8' services: db: image: postgis/postgis:16-3.4 environment: POSTGRES_DB: keina_db POSTGRES_USER: keina_user POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432" backend: build: ./backend command: python manage.py runserver 0.0.0.0:8000 volumes: - ./backend:/app ports: - "8000:8000" environment: DATABASE_URL: postgis://keina_user:${DB_PASSWORD}@db:5432/keina_db SECRET_KEY: ${SECRET_KEY} depends_on: - db frontend: build: ./frontend command: npm run dev volumes: - ./frontend:/app - /app/node_modules ports: - "3000:3000" environment: NEXT_PUBLIC_API_URL: http://localhost:8000 volumes: postgres_data: ``` ### Step 2: Django セットアップ(Day 1-2) #### backend/requirements.txt ```txt Django==5.0 djangorestframework==3.14 django-cors-headers==4.3 psycopg2-binary==2.9 djoser==2.2 djangorestframework-simplejwt==5.3 pandas==2.1 odfpy==1.4 WeasyPrint==60.1 ``` #### backend/keinasystem/settings.py(重要な設定のみ) ```python INSTALLED_APPS = [ # Django標準 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.gis', # GeoDjango # サードパーティ 'rest_framework', 'rest_framework_simplejwt', 'djoser', 'corsheaders', # 自作アプリ 'apps.fields', 'apps.plans', 'apps.reports', ] # PostGIS設定 DATABASES = { 'default': { 'ENGINE': 'django.contrib.gis.db.backends.postgis', 'NAME': 'keina_db', 'USER': 'keina_user', 'PASSWORD': os.getenv('DB_PASSWORD'), 'HOST': 'db', 'PORT': '5432', } } # REST Framework設定 REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], } # JWT設定 from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), } # CORS設定 CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", ] ``` ### Step 3: データモデル実装(Day 3) #### apps/fields/models.py ```python from django.contrib.gis.db import models class OfficialKyosaiField(models.Model): """共済マスタ(水稲共済細目用.ods)""" k_num = models.IntegerField("耕地番号") s_num = models.IntegerField("分筆番号") address = models.CharField("地名地番", max_length=200) kanji_name = models.CharField("漢字地名", max_length=200) area = models.FloatField("本地面積(m2)") class Meta: unique_together = [['k_num', 's_num']] ordering = ['k_num', 's_num'] class OfficialChusankanField(models.Model): """中山間マスタ(中山間.ods)""" c_id = models.IntegerField("ID", unique=True) oaza = models.CharField("大字", max_length=100) aza = models.CharField("字", max_length=100) chiban = models.IntegerField("地番") area = models.IntegerField("農地面積(m2)") payment_amount = models.IntegerField("交付金額", null=True, blank=True) class Meta: ordering = ['c_id'] class Field(models.Model): """実圃場(吉田農地台帳.ods)""" name = models.CharField("名称", max_length=100) address = models.CharField("住所", max_length=200) area_tan = models.FloatField("面積(反)") area_m2 = models.IntegerField("面積(m2)") # area_tan * 1000 owner_name = models.CharField("地主", max_length=100) # 紐付けキー(raw値) raw_kyosai_k_num = models.IntegerField("細目_耕地番号") raw_kyosai_s_num = models.IntegerField("細目_分筆番号") raw_chusankan_id = models.IntegerField("中山間_ID", null=True, blank=True) # 外部キー(紐付け済み) kyosai_field = models.ForeignKey( OfficialKyosaiField, on_delete=models.SET_NULL, null=True, related_name='fields' ) chusankan_field = models.ForeignKey( OfficialChusankanField, on_delete=models.SET_NULL, null=True, related_name='fields' ) # 地理情報(Phase 1では使用しないが、構造だけ準備) location = models.PointField(null=True, blank=True) class Meta: ordering = ['name'] ``` #### apps/plans/models.py ```python from django.db import models from apps.fields.models import Field class Crop(models.Model): """作物マスタ""" name = models.CharField("作物名", max_length=50, unique=True) is_planting = models.BooleanField("作付けする", default=True) class Meta: ordering = ['name'] class Variety(models.Model): """品種マスタ""" crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties') name = models.CharField("品種名", max_length=100) class Meta: ordering = ['crop', 'name'] class Plan(models.Model): """作付け計画""" field = models.ForeignKey(Field, on_delete=models.CASCADE, related_name='plans') year = models.IntegerField("年度") crop = models.ForeignKey(Crop, on_delete=models.PROTECT, null=True, blank=True) variety = models.ForeignKey(Variety, on_delete=models.SET_NULL, null=True, blank=True) notes = models.TextField("備考", blank=True) class Meta: unique_together = [['field', 'year']] ordering = ['year', 'field'] ``` ### Step 4: インポート機能(Day 4) #### apps/fields/views.py(インポートAPI) ```python from rest_framework.decorators import api_view from rest_framework.response import Response import pandas as pd @api_view(['POST']) def import_kyosai_master(request): """共済マスタのインポート""" file = request.FILES['file'] df = pd.read_excel(file, engine='odf') for _, row in df.iterrows(): OfficialKyosaiField.objects.update_or_create( k_num=row['耕地番号'], s_num=row['分筆番号'], defaults={ 'address': row['地名 地番'], 'kanji_name': row['漢字地名'], 'area': row['本地面積 (m2)'], } ) return Response({'status': 'success', 'imported': len(df)}) @api_view(['POST']) def import_yoshida_fields(request): """吉田農地台帳のインポート(紐付け処理含む)""" file = request.FILES['file'] df = pd.read_excel(file, engine='odf') for _, row in df.iterrows(): # 共済マスタとの紐付け try: kyosai = OfficialKyosaiField.objects.get( k_num=row['細目_耕地番号'], s_num=row['細目_分筆番号'] ) except OfficialKyosaiField.DoesNotExist: kyosai = None # 中山間マスタとの紐付け chusankan = None if pd.notna(row['中山間_ID']): try: chusankan = OfficialChusankanField.objects.get( c_id=int(row['中山間_ID']) ) except OfficialChusankanField.DoesNotExist: pass # 実圃場を作成 Field.objects.update_or_create( name=row['名称'], defaults={ 'address': row['住所'], 'area_tan': row['面積(反)'], 'area_m2': int(row['面積(反)'] * 1000), 'owner_name': row['地主'], 'raw_kyosai_k_num': row['細目_耕地番号'], 'raw_kyosai_s_num': row['細目_分筆番号'], 'raw_chusankan_id': int(row['中山間_ID']) if pd.notna(row['中山間_ID']) else None, 'kyosai_field': kyosai, 'chusankan_field': chusankan, } ) return Response({'status': 'success', 'imported': len(df)}) ``` ### Step 5: 作付け計画API(Day 5) #### apps/plans/views.py ```python from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from .models import Plan from .serializers import PlanSerializer class PlanViewSet(viewsets.ModelViewSet): queryset = Plan.objects.all() serializer_class = PlanSerializer def get_queryset(self): queryset = super().get_queryset() year = self.request.query_params.get('year') if year: queryset = queryset.filter(year=year) return queryset @action(detail=False, methods=['post']) def bulk_update(self, request): """一括更新""" field_ids = request.data.get('field_ids', []) crop_id = request.data.get('crop_id') variety_id = request.data.get('variety_id') year = request.data.get('year') for field_id in field_ids: Plan.objects.update_or_create( field_id=field_id, year=year, defaults={ 'crop_id': crop_id, 'variety_id': variety_id, } ) return Response({'status': 'success', 'updated': len(field_ids)}) @action(detail=False, methods=['post']) def copy_from_previous_year(self, request): """前年度コピー""" source_year = request.data.get('source_year') target_year = request.data.get('target_year') plans = Plan.objects.filter(year=source_year) for plan in plans: Plan.objects.update_or_create( field=plan.field, year=target_year, defaults={ 'crop': plan.crop, 'variety': plan.variety, 'notes': plan.notes, } ) return Response({'status': 'success', 'copied': len(plans)}) ``` ### Step 6: 申請書PDF生成(Day 7) #### apps/reports/views.py ```python from rest_framework.decorators import api_view from django.http import HttpResponse from django.template.loader import render_to_string from weasyprint import HTML from apps.fields.models import OfficialKyosaiField, OfficialChusankanField from apps.plans.models import Plan @api_view(['GET']) def generate_kyosai_pdf(request): """水稲共済細目書PDF""" year = int(request.query_params.get('year')) # 1. データ集約 output_rows = [] for kyosai in OfficialKyosaiField.objects.all().order_by('k_num', 's_num'): # この共済区画に紐づく実圃場を取得 fields = kyosai.fields.all() # 各実圃場の作付け計画を取得 plans = Plan.objects.filter( field__in=fields, year=year ).select_related('crop', 'variety') # 作物名と品種を集約 crops = sorted(list(set([p.crop.name for p in plans if p.crop]))) varieties = sorted(list(set([p.variety.name for p in plans if p.variety]))) output_rows.append({ 'k_num': kyosai.k_num, 's_num': kyosai.s_num, 'address': kyosai.address, 'kanji_name': kyosai.kanji_name, 'area': kyosai.area, 'crops': ','.join(crops) if crops else '未設定', 'varieties': ','.join(varieties) if varieties else '', 'note': f'{len(fields)}筆合算' if len(fields) > 1 else '' }) # 2. HTMLテンプレートで表を生成 html = render_to_string('reports/kyosai_template.html', { 'year': year, 'rows': output_rows }) # 3. HTML → PDF変換 pdf = HTML(string=html).write_pdf() response = HttpResponse(pdf, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="kyosai_{year}.pdf"' return response @api_view(['GET']) def generate_chusankan_pdf(request): """中山間交付金PDF""" year = int(request.query_params.get('year')) # データ集約(共済と同様) output_rows = [] for chusankan in OfficialChusankanField.objects.all().order_by('c_id'): fields = chusankan.fields.all() plans = Plan.objects.filter(field__in=fields, year=year).select_related('crop', 'variety') crops = sorted(list(set([p.crop.name for p in plans if p.crop]))) varieties = sorted(list(set([p.variety.name for p in plans if p.variety]))) output_rows.append({ 'c_id': chusankan.c_id, 'oaza': chusankan.oaza, 'aza': chusankan.aza, 'chiban': chusankan.chiban, 'area': chusankan.area, 'crops': ','.join(crops) if crops else '未設定', 'varieties': ','.join(varieties) if varieties else '', 'note': f'{len(fields)}筆合算' if len(fields) > 1 else '' }) html = render_to_string('reports/chusankan_template.html', { 'year': year, 'rows': output_rows }) pdf = HTML(string=html).write_pdf() response = HttpResponse(pdf, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="chusankan_{year}.pdf"' return response ``` #### apps/reports/templates/reports/kyosai_template.html ```html

水稲共済細目書({{ year }}年度)

{% for row in rows %} {% endfor %}
耕地番号 分筆番号 地名地番 漢字地名 本地面積
(m2)
作付品目 品種 備考
{{ row.k_num }} {{ row.s_num }} {{ row.address }} {{ row.kanji_name }} {{ row.area }} {{ row.crops }} {{ row.varieties }} {{ row.note }}
``` #### apps/reports/templates/reports/chusankan_template.html ```html

中山間地域等直接支払交付金({{ year }}年度)

{% for row in rows %} {% endfor %}
ID 大字 地番 農地面積
(m2)
作付品目 品種 備考
{{ row.c_id }} {{ row.oaza }} {{ row.aza }} {{ row.chiban }} {{ row.area }} {{ row.crops }} {{ row.varieties }} {{ row.note }}
``` #### apps/reports/urls.py ```python from django.urls import path from . import views urlpatterns = [ path('kyosai/', views.generate_kyosai_pdf, name='kyosai_pdf'), path('chusankan/', views.generate_chusankan_pdf, name='chusankan_pdf'), ] ``` ### Step 7: フロントエンド実装(Day 6, 8-9) #### frontend/app/reports/page.tsx(申請書ダウンロード画面) ```tsx 'use client' import { useState } from 'react' export default function ReportsPage() { const [year, setYear] = useState(2025) const downloadPDF = async (type: 'kyosai' | 'chusankan') => { const endpoint = type === 'kyosai' ? `/api/reports/kyosai/?year=${year}` : `/api/reports/chusankan/?year=${year}` try { const response = await fetch(`http://localhost:8000${endpoint}`, { headers: { 'Authorization': `JWT ${localStorage.getItem('accessToken')}` } }) const blob = await response.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = type === 'kyosai' ? `水稲共済細目書_${year}年度.pdf` : `中山間交付金_${year}年度.pdf` document.body.appendChild(a) a.click() window.URL.revokeObjectURL(url) document.body.removeChild(a) } catch (error) { console.error('PDF download failed:', error) alert('PDFのダウンロードに失敗しました') } } const previewPDF = (type: 'kyosai' | 'chusankan') => { const endpoint = type === 'kyosai' ? `/api/reports/kyosai/?year=${year}` : `/api/reports/chusankan/?year=${year}` const url = `http://localhost:8000${endpoint}` window.open(url, '_blank') } return (

申請書ダウンロード

{/* 水稲共済細目書 */}

📄 水稲共済細目書

提出時期: 2月・5月(年2回)

区画数: 31区画

{/* 中山間交付金 */}

📄 中山間地域等直接支払交付金

提出時期: 5月(年1回)

区画数: 71区画

) } ``` --- ## ⚠️ 重要な実装上の注意点 ### 1. データベース設計 - **面積単位**: DB内部は全て `m2` で保存、表示時に `反` に変換 - **紐付けキー**: `raw_*` フィールドと外部キー `*_field` の両方を持つ - **ユニーク制約**: `(field, year)` で作付け計画は1つまで ### 2. 申請書CSV生成 - **共済マスタをベースにループ**: 実圃場ベースではない - **作物の名寄せ**: Pythonのセットで重複排除 - **未割当の扱い**: 「未設定」として出力 ### 3. UI/UX - **未割当の強調**: 赤または黄色の背景色 - **スマホ対応**: 文字サイズ16px以上、タップ領域44px以上 - **検索のリアルタイム性**: `useState` + `filter` で実装 ### 4. 認証 - **JWT認証**: アクセストークン(1日)+ リフレッシュトークン(7日) - **シンプルな実装**: パスワードリセットはPhase 2 ### 5. パフォーマンス - **Phase 1では最適化不要**: 圃場数39筆、共済31区画、中山間71区画 → 十分軽い - **Phase 2で検討**: 栽培履歴が増えたらページネーション --- ## 📝 実装チェックリスト 実装完了時に、以下を全て確認してください: ### 環境構築 - [ ] `docker-compose up` でコンテナが全て起動する - [ ] PostgreSQL + PostGIS が正常に動作する - [ ] Django の `makemigrations` / `migrate` が成功する - [ ] Next.js の `npm run dev` が成功する ### バックエンド - [ ] `/admin` でDjango管理画面にアクセスできる - [ ] `/api/auth/jwt/create/` でJWTトークンを取得できる - [ ] `/api/fields/` で圃場一覧を取得できる - [ ] `/api/plans/?year=2025` で作付け計画を取得できる - [ ] `/api/reports/kyosai/?year=2025` でPDFをダウンロードできる - [ ] `/api/reports/chusankan/?year=2025` でPDFをダウンロードできる ### フロントエンド - [ ] `/login` でログインできる - [ ] `/allocation` で作付け計画一覧を表示できる - [ ] 作付け計画を編集できる - [ ] `/reports` で申請書をダウンロードできる - [ ] PDFをプレビュー表示できる(新しいタブ) - [ ] スマホで見やすい(Chrome DevToolsで確認) ### データ整合性 - [ ] 共済PDFの耕地番号が正しい - [ ] 中山間PDFのIDが正しい - [ ] 作物の名寄せが正しい - [ ] 未割当の圃場が適切に扱われる - [ ] PDFの表レイアウトが整っている - [ ] PDFをA4用紙に印刷して読める(フォントサイズ、余白) --- ## 🆘 トラブルシューティング ### PostGISが動かない ```python # settings.py に以下を追加 GDAL_LIBRARY_PATH = '/usr/lib/libgdal.so' GEOS_LIBRARY_PATH = '/usr/lib/libgeos_c.so' ``` ### ODSファイルが読めない ```bash pip install odfpy --break-system-packages ``` ### WeasyPrintのインストールエラー ```bash # Ubuntu/Debianの場合、システムライブラリが必要 apt-get install python3-cffi python3-brotli libpango-1.0-0 libpangoft2-1.0-0 pip install WeasyPrint --break-system-packages ``` ### PDFの日本語が表示されない ```python # HTMLテンプレートのCSSに日本語フォントを明示 body { font-family: "MS Gothic", "Yu Gothic", "Hiragino Sans", sans-serif; } ``` ### CORSエラー ```python # settings.py CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", ] ``` ### マイグレーションエラー ```bash docker-compose exec backend python manage.py makemigrations docker-compose exec backend python manage.py migrate ``` --- ## 🎉 実装完了の定義 以下が全て完了したら、Phase 1は実装完了とする: 1. ✅ ログインできる 2. ✅ 作付け計画を編集できる 3. ✅ 水稲共済PDFをダウンロードできる 4. ✅ 中山間PDFをダウンロードできる 5. ✅ PDFをプレビュー表示できる 6. ✅ PDFをA4用紙に印刷してそのまま提出できる品質 7. ✅ 前年度コピーができる 8. ✅ スマホで圃場情報を見られる 9. ✅ 実データ(3つのODSファイル)をインポートできる --- ## 📞 質問・不明点がある場合 実装中に疑問が生じた場合、以下を確認してください: 1. **データ仕様書.md**: データの紐付けロジックが不明な場合 2. **画面設計書.md**: UIの詳細が不明な場合 3. **ユーザーストーリー.md**: 機能の目的・受け入れ基準が不明な場合 それでも解決しない場合は、**実装を止めて質問してください**。 --- **Good luck with the implementation! 🚀**