# 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
| 耕地番号 | 分筆番号 | 地名地番 | 漢字地名 | 本地面積 (m2) |
作付品目 | 品種 | 備考 |
|---|---|---|---|---|---|---|---|
| {{ row.k_num }} | {{ row.s_num }} | {{ row.address }} | {{ row.kanji_name }} | {{ row.area }} | {{ row.crops }} | {{ row.varieties }} | {{ row.note }} |
| ID | 大字 | 字 | 地番 | 農地面積 (m2) |
作付品目 | 品種 | 備考 |
|---|---|---|---|---|---|---|---|
| {{ row.c_id }} | {{ row.oaza }} | {{ row.aza }} | {{ row.chiban }} | {{ row.area }} | {{ row.crops }} | {{ row.varieties }} | {{ row.note }} |
提出時期: 2月・5月(年2回)
区画数: 31区画
提出時期: 5月(年1回)
区画数: 71区画