937 lines
29 KiB
Markdown
937 lines
29 KiB
Markdown
# 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
|
||
<!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
|
||
```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
|
||
```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 (
|
||
<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つまで
|
||
|
||
### 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! 🚀**
|