初期仕様案
This commit is contained in:
936
00_Gemini向け統合指示書.md
Normal file
936
00_Gemini向け統合指示書.md
Normal file
@@ -0,0 +1,936 @@
|
||||
# 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! 🚀**
|
||||
Reference in New Issue
Block a user