# Keina System - Gemini向け実装指示書 ## 📋 このドキュメントについて これは、農業生産者向けの作付け計画管理システム「Keina System」の実装に必要な全情報を統合したドキュメントです。 **実装エージェント(Gemini/OpenCode)は、以下の順序でドキュメントを読み、理解してから実装を開始してください。** --- ## 🎯 システムの全体像 ### 目的 年間の作付け計画を管理し、役場への申請書類(水稲共済細目書・中山間地域等直接支払交付金)を自動生成するシステム。 ### ユーザー - 65歳の農家(元プログラマー) - シングルユーザー(マルチテナント不要) - PCで登録・編集、スマホで参照 ### 主要機能(Phase 1 / MVP) 1. 作付け計画の一覧表示・インライン編集 2. 水稲共済細目書のPDF出力 3. 中山間交付金申請書のPDF出力 4. 前年度作付けのコピー機能 5. 圃場情報のスマホ参照 ### 技術スタック - **バックエンド**: Django 5.2 + 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:N関係)の詳細 - 申請書PDF出力のアルゴリズム - **👉 読むべき理由**: データモデルの設計ミスは後から修正困難 ### 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.2 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.CharField("耕地番号", max_length=20) s_num = models.CharField("分筆番号", max_length=20, blank=True) address = models.CharField("地名地番", max_length=200) kanji_name = models.CharField("漢字地名", max_length=200) area = models.IntegerField("本地面積(m2)") class Meta: unique_together = [['k_num', 's_num']] ordering = ['k_num', 's_num'] class OfficialChusankanField(models.Model): """中山間マスタ(中山間.ods)- 17列全て保存""" c_id = models.CharField("ID", max_length=20, unique=True) chusankan_flag = models.CharField("中山間", max_length=10, blank=True) oaza = models.CharField("大字", max_length=100) aza = models.CharField("字", max_length=100) chiban = models.CharField("地番", max_length=50) branch_num = models.CharField("枝番", max_length=20, blank=True) land_type = models.CharField("地目", max_length=20, blank=True) area = models.IntegerField("農地面積(m2)") planting_area = models.IntegerField("植栽面積(m2)", null=True, blank=True) original_crop = models.CharField("作付け品目", max_length=100, blank=True) manager = models.CharField("協定管理者", max_length=100, blank=True) owner = models.CharField("所有者", max_length=100, blank=True) slope = models.CharField("傾斜度", max_length=20, blank=True) base_amount = models.IntegerField("基本金額", null=True, blank=True) steep_slope_addition = models.IntegerField("超急傾斜加算額", null=True, blank=True) smart_agri_addition = models.IntegerField("スマート農業加算額", null=True, blank=True) 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) # グループ・表示順 group_name = models.CharField("グループ名", max_length=100, blank=True) display_order = models.IntegerField("表示順", default=0) # M:N紐付け(1つの圃場が複数の申請区画に紐づく場合がある) kyosai_fields = models.ManyToManyField( OfficialKyosaiField, blank=True, related_name='fields' ) chusankan_fields = models.ManyToManyField( OfficialChusankanField, blank=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 フィールドは削除(「その他」で統一するため) 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'] unique_together = [['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): """吉田農地台帳のインポート(M:N紐付け処理含む)""" file = request.FILES['file'] df = pd.read_excel(file, engine='odf') for _, row in df.iterrows(): # 実圃場を作成 field, created = Field.objects.update_or_create( name=row['名称'], defaults={ 'address': row['住所'], 'area_tan': row['面積(反)'], 'area_m2': int(row['面積(反)'] * 1000), 'owner_name': row['地主'], } ) # 共済マスタとのM:N紐付け try: kyosai = OfficialKyosaiField.objects.get( k_num=str(row['細目_耕地番号']), s_num=str(row['細目_分筆番号']) ) field.kyosai_fields.add(kyosai) except OfficialKyosaiField.DoesNotExist: pass # 中山間マスタとのM:N紐付け if pd.notna(row.get('中山間_ID')): try: chusankan = OfficialChusankanField.objects.get( c_id=str(int(row['中山間_ID'])) ) field.chusankan_fields.add(chusankan) except OfficialChusankanField.DoesNotExist: pass return Response({'status': 'success', 'imported': len(df)}) ``` ### Step 5: 作付け計画API(Day 5) #### 作物・品種マスタについて 初期データの自動投入は行わない。作物・品種はDjango管理画面またはUIから手動で登録する運用とする。 主な作物: 米、トウモロコシ、エンドウ、野菜、その他 #### apps/plans/views.py ```python from rest_framework import viewsets from rest_framework.decorators import action, api_view from rest_framework.response import Response from .models import Plan, Crop, Variety from .serializers import PlanSerializer, CropSerializer, VarietySerializer 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)}) @action(detail=False, methods=['get']) def summary(self, request): """集計(サイドバー用)""" year = int(request.query_params.get('year')) plans = Plan.objects.filter(year=year).select_related('crop', 'variety', 'field') # 作物別集計 crop_summary = {} total_area = 0 for plan in plans: crop_name = plan.crop.name if plan.crop else '未設定' variety_name = plan.variety.name if plan.variety else '(品種なし)' area = plan.field.area_tan total_area += area if crop_name not in crop_summary: crop_summary[crop_name] = {'total': 0, 'varieties': {}} crop_summary[crop_name]['total'] += area if variety_name not in crop_summary[crop_name]['varieties']: crop_summary[crop_name]['varieties'][variety_name] = 0 crop_summary[crop_name]['varieties'][variety_name] += area # 反を小数点1桁に丸める for crop in crop_summary.values(): crop['total'] = round(crop['total'], 1) for variety in crop['varieties']: crop['varieties'][variety] = round(crop['varieties'][variety], 1) return Response({ 'total_area': round(total_area, 1), 'crops': crop_summary }) @api_view(['POST']) def add_variety(request): """新しい品種を追加""" crop_id = request.data.get('crop_id') name = request.data.get('name') # 重複チェック if Variety.objects.filter(crop_id=crop_id, name=name).exists(): return Response({'error': 'この品種は既に登録されています'}, status=400) variety = Variety.objects.create(crop_id=crop_id, name=name) return Response({ 'id': variety.id, 'name': variety.name, 'crop_id': variety.crop_id }) @api_view(['GET']) def get_crops_with_varieties(request): """作物と品種の一覧を取得""" crops = Crop.objects.prefetch_related('varieties').all() data = [] for crop in crops: data.append({ 'id': crop.id, 'name': crop.name, 'varieties': [ {'id': v.id, 'name': v.name} for v in crop.varieties.all() ] }) return Response(data) ``` ### 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区画