Files
keinasystem/document/00_Gemini向け統合指示書.md
2026-02-15 10:56:50 +09:00

35 KiB
Raw Blame History

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

# プロジェクト構造
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

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

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重要な設定のみ

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

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

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

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: 作付け計画APIDay 5

作物・品種の初期データ投入

apps/plans/management/commands/init_crops.py

from django.core.management.base import BaseCommand
from apps.plans.models import Crop, Variety

class Command(BaseCommand):
    help = '作物・品種マスタの初期データを投入'
    
    def handle(self, *args, **options):
        # 作物マスタと品種
        crops_data = {
            '米': ['にこまる', 'たちはるか', 'たちはるか(特栽)'],
            'トウモロコシ': [],
            'エンドウ': ['久留米豊'],
            '野菜': [],
            'その他': [
                '完全休耕',
                '緑肥(ヘアリーベッチ)',
                '緑肥(レンゲ)',
                '景観作物(コスモス)',
                '景観作物(ヒマワリ)'
            ]
        }
        
        for crop_name, varieties in crops_data.items():
            crop, created = Crop.objects.get_or_create(name=crop_name)
            if created:
                self.stdout.write(f'作物「{crop_name}」を作成')
            
            for variety_name in varieties:
                variety, created = Variety.objects.get_or_create(
                    crop=crop, 
                    name=variety_name
                )
                if created:
                    self.stdout.write(f'  品種「{variety_name}」を追加')
        
        self.stdout.write(self.style.SUCCESS('初期データ投入完了'))

実行:

docker-compose exec backend python manage.py init_crops

apps/plans/views.py

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

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

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        @page {
            size: A4;
            margin: 2cm;
        }
        body {
            font-family: "MS Gothic", "Yu Gothic", monospace;
            font-size: 10pt;
        }
        h1 {
            text-align: center;
            font-size: 14pt;
            margin-bottom: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            font-size: 9pt;
        }
        th, td {
            border: 1px solid black;
            padding: 4px;
            text-align: center;
        }
        th {
            background-color: #f0f0f0;
            font-weight: bold;
        }
        .left {
            text-align: left;
        }
    </style>
</head>
<body>
    <h1>水稲共済細目書({{ year }}年度)</h1>
    <table>
        <thead>
            <tr>
                <th style="width: 8%;">耕地番号</th>
                <th style="width: 8%;">分筆番号</th>
                <th style="width: 20%;">地名地番</th>
                <th style="width: 18%;">漢字地名</th>
                <th style="width: 10%;">本地面積<br>(m2)</th>
                <th style="width: 12%;">作付品目</th>
                <th style="width: 14%;">品種</th>
                <th style="width: 10%;">備考</th>
            </tr>
        </thead>
        <tbody>
            {% for row in rows %}
            <tr>
                <td>{{ row.k_num }}</td>
                <td>{{ row.s_num }}</td>
                <td class="left">{{ row.address }}</td>
                <td class="left">{{ row.kanji_name }}</td>
                <td>{{ row.area }}</td>
                <td class="left">{{ row.crops }}</td>
                <td class="left">{{ row.varieties }}</td>
                <td>{{ row.note }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</body>
</html>

apps/reports/templates/reports/chusankan_template.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        @page { size: A4; margin: 2cm; }
        body { font-family: "MS Gothic", "Yu Gothic", monospace; font-size: 10pt; }
        h1 { text-align: center; font-size: 14pt; margin-bottom: 20px; }
        table { width: 100%; border-collapse: collapse; font-size: 9pt; }
        th, td { border: 1px solid black; padding: 4px; text-align: center; }
        th { background-color: #f0f0f0; font-weight: bold; }
        .left { text-align: left; }
    </style>
</head>
<body>
    <h1>中山間地域等直接支払交付金({{ year }}年度)</h1>
    <table>
        <thead>
            <tr>
                <th style="width: 8%;">ID</th>
                <th style="width: 15%;">大字</th>
                <th style="width: 15%;"></th>
                <th style="width: 10%;">地番</th>
                <th style="width: 12%;">農地面積<br>(m2)</th>
                <th style="width: 15%;">作付品目</th>
                <th style="width: 15%;">品種</th>
                <th style="width: 10%;">備考</th>
            </tr>
        </thead>
        <tbody>
            {% for row in rows %}
            <tr>
                <td>{{ row.c_id }}</td>
                <td class="left">{{ row.oaza }}</td>
                <td class="left">{{ row.aza }}</td>
                <td>{{ row.chiban }}</td>
                <td>{{ row.area }}</td>
                <td class="left">{{ row.crops }}</td>
                <td class="left">{{ row.varieties }}</td>
                <td>{{ row.note }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</body>
</html>

apps/reports/urls.py

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申請書ダウンロード画面

'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 (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">申請書ダウンロード</h1>
      
      <div className="mb-4">
        <label className="mr-2">年度:</label>
        <select 
          value={year} 
          onChange={e => setYear(parseInt(e.target.value))}
          className="border rounded px-4 py-2"
        >
          <option value={2024}>2024年度</option>
          <option value={2025}>2025年度</option>
          <option value={2026}>2026年度</option>
        </select>
      </div>
      
      <div className="grid gap-4">
        {/* 水稲共済細目書 */}
        <div className="border rounded p-4">
          <h2 className="text-xl font-bold mb-2">📄 水稲共済細目書</h2>
          <p className="text-sm text-gray-600 mb-2">提出時期: 2月5月(年2回)</p>
          <p className="text-sm text-gray-600 mb-4">区画数: 31区画</p>
          
          <div className="flex gap-2">
            <button 
              onClick={() => previewPDF('kyosai')}
              className="bg-blue-500 text-white px-4 py-2 rounded"
            >
              プレビュー
            </button>
            <button 
              onClick={() => downloadPDF('kyosai')}
              className="bg-green-500 text-white px-4 py-2 rounded"
            >
              PDFダウンロード
            </button>
          </div>
        </div>
        
        {/* 中山間交付金 */}
        <div className="border rounded p-4">
          <h2 className="text-xl font-bold mb-2">📄 中山間地域等直接支払交付金</h2>
          <p className="text-sm text-gray-600 mb-2">提出時期: 5月(年1回)</p>
          <p className="text-sm text-gray-600 mb-4">区画数: 71区画</p>
          
          <div className="flex gap-2">
            <button 
              onClick={() => previewPDF('chusankan')}
              className="bg-blue-500 text-white px-4 py-2 rounded"
            >
              プレビュー
            </button>
            <button 
              onClick={() => downloadPDF('chusankan')}
              className="bg-green-500 text-white px-4 py-2 rounded"
            >
              PDFダウンロード
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

⚠️ 重要な実装上の注意点

1. データベース設計

  • 面積単位: DB内部は全て m2 で保存、表示時に に変換
  • 紐付けキー: raw_* フィールドと外部キー *_field の両方を持つ
  • ユニーク制約: (field, year) で作付け計画は1つまで
  • 品種マスタ: (crop, name) で一意制約

2. 作物・品種の統一

  • すべての作物で品種選択UIは統一: 作物による操作の違いなし
  • 「作付けしない」系も特別扱いしない: 「その他」という作物に統一
  • 品種の追加: その場で追加可能、データベースに永続化
  • 初期データ:
    米: にこまる、たちはるか、たちはるか(特栽)
    トウモロコシ: (ユーザーが追加)
    エンドウ: 久留米豊
    野菜: (ユーザーが追加)
    その他: 完全休耕、緑肥(ヘアリーベッチ)、緑肥(レンゲ)、景観作物(コスモス)、景観作物(ヒマワリ)
    

3. 集計サイドバー

  • リアルタイム更新: 作付け計画を保存するたびに自動更新
  • PC: 開閉可能なサイドバー幅200px固定
  • スマホ: モーダル表示
  • 集計API: /api/plans/summary/?year=2025 で作物別・品種別の面積を返す

4. 申請書PDF生成

  • 共済マスタをベースにループ: 実圃場ベースではない
  • 作物の名寄せ: Pythonのセットで重複排除
  • 未割当の扱い: 「未設定」として出力

5. UI/UX

  • 未割当の強調: 赤または黄色の背景色
  • スマホ対応: 文字サイズ16px以上、タップ領域44px以上
  • 検索のリアルタイム性: useState + filter で実装
  • 品種追加のUI: インライン入力、別画面に遷移しない

6. 認証

  • JWT認証: アクセストークン1日+ リフレッシュトークン7日
  • シンプルな実装: パスワードリセットはPhase 2

7. パフォーマンス

  • 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が動かない

# settings.py に以下を追加
GDAL_LIBRARY_PATH = '/usr/lib/libgdal.so'
GEOS_LIBRARY_PATH = '/usr/lib/libgeos_c.so'

ODSファイルが読めない

pip install odfpy --break-system-packages

WeasyPrintのインストールエラー

# Ubuntu/Debianの場合、システムライブラリが必要
apt-get install python3-cffi python3-brotli libpango-1.0-0 libpangoft2-1.0-0
pip install WeasyPrint --break-system-packages

PDFの日本語が表示されない

# HTMLテンプレートのCSSに日本語フォントを明示
body {
    font-family: "MS Gothic", "Yu Gothic", "Hiragino Sans", sans-serif;
}

CORSエラー

# settings.py
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
]

マイグレーションエラー

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! 🚀