E-1 完了サマリー

実施内容
#	変更内容	ファイル
1	OfficialChusankanField に 11 フィールド追加(17列化)	models.py
2	中山間インポート: 17 列すべて読み込み対応	views.py
3	共済インポート: 面積カラム名不一致バグ修正 + a→m2 変換(×100)	views.py
4	シリアライザに 11 フィールド追加	serializers.py
5	共済 PDF: A4 縦、表形式、@page 設定、ページ番号、中国語除去	kyosai_template.html
6	中山間 PDF: A4 横、表形式、@page 設定、ページ番号、中国語除去	chusankan_template.html
7	PDF 生成ロジック: フラットテーブル、null 安全、prefetch_related	reports/views.py
8	既存データ再インポート(共済面積修正 + 中山間 17 列埋め)	—
9	Playwright E2E テスト 11 件全 PASS	verify-fixes.spec.ts
追加発見・修正したバグ
共済 ODS の 本地面積 (m2) カラム名にスペースが含まれ、インポート時に面積が全件 0 になっていた
面積の単位がアール(a)であることが判明。m2 への変換 (×100) を追加
PDF は http://localhost:3000/reports からダウンロードして確認できます。
This commit is contained in:
Akira
2026-02-17 15:27:14 +09:00
parent 85362d40c9
commit d70b5ee551
13 changed files with 584 additions and 192 deletions

View File

@@ -12,7 +12,12 @@
"Bash(curl:*)", "Bash(curl:*)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(npx playwright install:*)", "Bash(npx playwright install:*)",
"Bash(claude mcp add:*)" "Bash(claude mcp add:*)",
"Bash(ls:*)",
"Bash(docker ps:*)",
"Bash(docker exec:*)",
"Bash(npx playwright test:*)",
"Bash(docker restart:*)"
] ]
} }
} }

View File

@@ -0,0 +1,78 @@
# Generated by Django 5.0 on 2026-02-17 06:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fields', '0005_c2_kyosai_unique_together_c4_area_to_m2_integer'),
]
operations = [
migrations.AddField(
model_name='officialchusankanfield',
name='base_amount',
field=models.IntegerField(blank=True, null=True, verbose_name='基本金額'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='branch_num',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='枝番'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='chusankan_flag',
field=models.CharField(blank=True, max_length=10, null=True, verbose_name='中山間フラグ'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='land_type',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='地目'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='manager',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='協定管理者'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='original_crop',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='作付け品目'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='owner',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='所有者'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='planting_area',
field=models.IntegerField(blank=True, null=True, verbose_name='植栽面積(m2)'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='slope',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='傾斜度'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='smart_agri_addition',
field=models.IntegerField(blank=True, null=True, verbose_name='スマート農業加算額'),
),
migrations.AddField(
model_name='officialchusankanfield',
name='steep_slope_addition',
field=models.IntegerField(blank=True, null=True, verbose_name='超急傾斜加算額'),
),
migrations.AlterField(
model_name='officialchusankanfield',
name='area',
field=models.IntegerField(default=0, verbose_name='農地面積(m2)'),
),
migrations.AlterField(
model_name='officialchusankanfield',
name='payment_amount',
field=models.IntegerField(blank=True, null=True, verbose_name='交付金額'),
),
]

View File

@@ -20,11 +20,22 @@ class OfficialKyosaiField(models.Model):
class OfficialChusankanField(models.Model): class OfficialChusankanField(models.Model):
c_id = models.CharField(max_length=20, unique=True, verbose_name="中山間ID") c_id = models.CharField(max_length=20, unique=True, verbose_name="中山間ID")
chusankan_flag = models.CharField(max_length=10, blank=True, null=True, verbose_name="中山間フラグ")
oaza = models.CharField(max_length=100, verbose_name="大字") oaza = models.CharField(max_length=100, verbose_name="大字")
aza = models.CharField(max_length=100, verbose_name="") aza = models.CharField(max_length=100, verbose_name="")
chiban = models.CharField(max_length=50, verbose_name="地番") chiban = models.CharField(max_length=50, verbose_name="地番")
area = models.IntegerField(default=0, verbose_name="面積(m2)") branch_num = models.CharField(max_length=20, blank=True, null=True, verbose_name="枝番")
payment_amount = models.IntegerField(blank=True, null=True, verbose_name="支払金額") land_type = models.CharField(max_length=20, blank=True, null=True, verbose_name="地目")
area = models.IntegerField(default=0, verbose_name="農地面積(m2)")
planting_area = models.IntegerField(blank=True, null=True, verbose_name="植栽面積(m2)")
original_crop = models.CharField(max_length=100, blank=True, null=True, verbose_name="作付け品目")
manager = models.CharField(max_length=100, blank=True, null=True, verbose_name="協定管理者")
owner = models.CharField(max_length=100, blank=True, null=True, verbose_name="所有者")
slope = models.CharField(max_length=20, blank=True, null=True, verbose_name="傾斜度")
base_amount = models.IntegerField(blank=True, null=True, verbose_name="基本金額")
steep_slope_addition = models.IntegerField(blank=True, null=True, verbose_name="超急傾斜加算額")
smart_agri_addition = models.IntegerField(blank=True, null=True, verbose_name="スマート農業加算額")
payment_amount = models.IntegerField(blank=True, null=True, verbose_name="交付金額")
class Meta: class Meta:
verbose_name = "中山間マスタ" verbose_name = "中山間マスタ"

View File

@@ -11,7 +11,10 @@ class OfficialKyosaiFieldSerializer(serializers.ModelSerializer):
class OfficialChusankanFieldSerializer(serializers.ModelSerializer): class OfficialChusankanFieldSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = OfficialChusankanField model = OfficialChusankanField
fields = ['id', 'c_id', 'oaza', 'aza', 'chiban', 'area', 'payment_amount'] fields = ['id', 'c_id', 'chusankan_flag', 'oaza', 'aza', 'chiban', 'branch_num',
'land_type', 'area', 'planting_area', 'original_crop', 'manager', 'owner',
'slope', 'base_amount', 'steep_slope_addition', 'smart_agri_addition',
'payment_amount']
class FieldSerializer(serializers.ModelSerializer): class FieldSerializer(serializers.ModelSerializer):

View File

@@ -32,21 +32,33 @@ def import_kyosai_master(request):
try: try:
df = pd.read_excel(ods_file, engine='odf') df = pd.read_excel(ods_file, engine='odf')
df.columns = df.columns.str.strip() df.columns = df.columns.str.strip()
# ODS カラム名 "本地面積 (m2)" のスペース有無に対応
area_col = None
for col in df.columns:
if '本地面積' in col:
area_col = col
break
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
for _, row in df.iterrows(): for _, row in df.iterrows():
k_num = str(row.get('耕地番号', '')).strip() if pd.notna(row.get('耕地番号')) else '' k_num = str(row.get('耕地番号', '')).strip() if pd.notna(row.get('耕地番号')) else ''
s_num = str(row.get('分筆番号', '')).strip() if pd.notna(row.get('分筆番号')) else '' s_num = str(row.get('分筆番号', '')).strip() if pd.notna(row.get('分筆番号')) else ''
if not k_num: if not k_num:
continue continue
area_val = 0
if area_col and pd.notna(row.get(area_col)):
# ODS の値はアール(a)単位。m2 に変換 (1a = 100m2)
area_val = int(float(row.get(area_col)) * 100)
defaults = { defaults = {
'address': str(row.get('地名 地番', '')).strip() if pd.notna(row.get('地名 地番')) else '', 'address': str(row.get('地名 地番', '')).strip() if pd.notna(row.get('地名 地番')) else '',
'kanji_name': str(row.get('漢字地名', '')).strip() if pd.notna(row.get('漢字地名')) else '', 'kanji_name': str(row.get('漢字地名', '')).strip() if pd.notna(row.get('漢字地名')) else '',
'area': int(float(row.get('本地面積(m2)', 0))) if pd.notna(row.get('本地面積(m2)')) else 0, 'area': area_val,
} }
obj, created = OfficialKyosaiField.objects.update_or_create( obj, created = OfficialKyosaiField.objects.update_or_create(
@@ -169,37 +181,50 @@ def import_chusankan_master(request):
ods_file = request.FILES['file'] ods_file = request.FILES['file']
def safe_str(val, default=''):
return str(val).strip() if pd.notna(val) else default
def safe_int(val, default=None):
if pd.isna(val):
return default
try:
return int(float(val))
except (ValueError, TypeError):
return default
try: try:
df = pd.read_excel(ods_file, engine='odf') df = pd.read_excel(ods_file, engine='odf')
df.columns = df.columns.str.strip() df.columns = df.columns.str.strip()
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
for _, row in df.iterrows(): for _, row in df.iterrows():
raw_id = row.get('ID') raw_id = row.get('ID')
c_id = str(raw_id).strip() if pd.notna(raw_id) else '' c_id = str(raw_id).strip() if pd.notna(raw_id) else ''
if not c_id: if not c_id:
continue continue
if not any(char.isdigit() for char in c_id): if not any(char.isdigit() for char in c_id):
continue continue
try:
payment_amount_val = row.get('交付金額')
if pd.notna(payment_amount_val):
payment_amount = int(payment_amount_val)
else:
payment_amount = None
except (ValueError, TypeError):
payment_amount = None
defaults = { defaults = {
'oaza': str(row.get('大字', '')).strip() if pd.notna(row.get('大字')) else '', 'chusankan_flag': safe_str(row.get('中山間')) or None,
'aza': str(row.get('', '')).strip() if pd.notna(row.get('')) else '', 'oaza': safe_str(row.get('')),
'chiban': str(row.get('地番', '')).strip() if pd.notna(row.get('地番')) else '', 'aza': safe_str(row.get('')),
'area': int(float(row.get('農地面積', 0))) if pd.notna(row.get('農地面積')) else 0, 'chiban': safe_str(row.get('地番')),
'payment_amount': payment_amount, 'branch_num': safe_str(row.get('枝番')) or None,
'land_type': safe_str(row.get('地目')) or None,
'area': safe_int(row.get('農地面積'), 0),
'planting_area': safe_int(row.get('植栽面積')),
'original_crop': safe_str(row.get('作付け品目')) or None,
'manager': safe_str(row.get('協定管理者')) or None,
'owner': safe_str(row.get('所有者')) or None,
'slope': safe_str(row.get('傾斜度')) or None,
'base_amount': safe_int(row.get('基本金額')),
'steep_slope_addition': safe_int(row.get('超急傾斜加算額')),
'smart_agri_addition': safe_int(row.get('スマート農業加算額')),
'payment_amount': safe_int(row.get('交付金額')),
} }
obj, created = OfficialChusankanField.objects.update_or_create( obj, created = OfficialChusankanField.objects.update_or_create(

View File

@@ -2,107 +2,84 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>中山間地域直接支払申请书</title> <title>中山間地域直接支払交付金({{ year }}年度)</title>
<style> <style>
@page {
size: A4 landscape;
margin: 10mm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { body {
font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", sans-serif; font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", sans-serif;
font-size: 10pt; font-size: 8pt;
line-height: 1.5; line-height: 1.3;
margin: 0;
} }
h1 { h1 {
text-align: center; text-align: center;
font-size: 14pt; font-size: 13pt;
margin-bottom: 20pt; margin: 0 0 8pt 0;
}
h2 {
font-size: 12pt;
border-bottom: 1px solid #333;
margin-top: 15pt;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 10pt;
} }
th, td { th, td {
border: 1px solid #333; border: 1px solid #333;
padding: 5pt; padding: 2pt 4pt;
text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
background-color: #f0f0f0; background-color: #f0f0f0;
font-size: 7pt;
text-align: center;
white-space: nowrap;
} }
.info-row { td {
display: flex; font-size: 7.5pt;
margin-bottom: 5pt;
} }
.info-label { .num {
font-weight: bold; text-align: right;
width: 100pt;
} }
.crop-table th { .empty {
width: 30%; color: #999;
}
.crop-table td {
width: 70%;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>中山間地域直接支払申请书 - {{ year }}年度</h1> <h1>中山間地域直接支払交付金({{ year }}年度</h1>
{% for item in data %} <table>
<h2>{{ item.chusankan.c_id }} - {{ item.chusankan.oaza }}{{ item.chusankan.aza }}</h2>
<div class="info-row">
<span class="info-label">大字:</span>
<span>{{ item.chusankan.oaza }}</span>
</div>
<div class="info-row">
<span class="info-label">字:</span>
<span>{{ item.chusankan.aza }}</span>
</div>
<div class="info-row">
<span class="info-label">地番:</span>
<span>{{ item.chusankan.chiban }}</span>
</div>
<div class="info-row">
<span class="info-label">面積:</span>
<span>{{ item.chusankan.area }} ha</span>
</div>
<div class="info-row">
<span class="info-label">支払金額:</span>
<span>{{ item.chusankan.payment_amount|default:"-" }} 円</span>
</div>
<div class="info-row">
<span class="info-label">関連圃場数:</span>
<span>{{ item.field_count }}</span>
</div>
<div class="info-row">
<span class="info-label">作付面積合計:</span>
<span>{{ item.total_area|floatformat:4 }} 反</span>
</div>
{% if item.crops %}
<table class="crop-table">
<thead> <thead>
<tr> <tr>
<th>所在地</th>
<th>植栽面積(m2)</th>
<th>作付品目(元)</th>
<th>協定管理者</th>
<th>所有者</th>
<th>作物</th> <th>作物</th>
<th>品種</th> <th>品種</th>
<th>圃場名称</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for crop in item.crops %} {% for row in rows %}
<tr> <tr>
<td>{{ crop.name }}</td> <td>{{ row.location }}</td>
<td>{{ crop.variety }}</td> <td class="num">{{ row.planting_area|default:"—" }}</td>
<td>{{ row.original_crop|default:"—" }}</td>
<td>{{ row.manager|default:"—" }}</td>
<td>{{ row.owner|default:"—" }}</td>
<td>{% if row.crop %}{{ row.crop }}{% else %}<span class="empty"></span>{% endif %}</td>
<td>{% if row.variety %}{{ row.variety }}{% else %}<span class="empty"></span>{% endif %}</td>
<td>{% if row.field_name %}{{ row.field_name }}{% else %}<span class="empty"></span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% endfor %}
</body> </body>
</html> </html>

View File

@@ -2,95 +2,80 @@
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>水稲共済申请书</title> <title>水稲共済細目書({{ year }}年度)</title>
<style> <style>
@page {
size: A4 portrait;
margin: 15mm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { body {
font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", sans-serif; font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", sans-serif;
font-size: 10pt; font-size: 9pt;
line-height: 1.5; line-height: 1.4;
margin: 0;
} }
h1 { h1 {
text-align: center; text-align: center;
font-size: 14pt; font-size: 14pt;
margin-bottom: 20pt; margin: 0 0 10pt 0;
}
h2 {
font-size: 12pt;
border-bottom: 1px solid #333;
margin-top: 15pt;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 10pt;
} }
th, td { th, td {
border: 1px solid #333; border: 1px solid #333;
padding: 5pt; padding: 3pt 5pt;
text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
background-color: #f0f0f0; background-color: #f0f0f0;
font-size: 8pt;
text-align: center;
white-space: nowrap;
} }
.info-row { td {
display: flex; font-size: 8.5pt;
margin-bottom: 5pt;
} }
.info-label { .num {
font-weight: bold; text-align: right;
width: 100pt;
} }
.crop-table th { .empty {
width: 30%; color: #999;
}
.crop-table td {
width: 70%;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>水稲共済申请书 - {{ year }}年度</h1> <h1>水稲共済細目書({{ year }}年度</h1>
{% for item in data %} <table>
<h2>{{ item.kyosai.k_num }} - {{ item.kyosai.kanji_name }}</h2>
<div class="info-row">
<span class="info-label">住所:</span>
<span>{{ item.kyosai.address }}</span>
</div>
<div class="info-row">
<span class="info-label">面積:</span>
<span>{{ item.kyosai.area }} ha</span>
</div>
<div class="info-row">
<span class="info-label">関連圃場数:</span>
<span>{{ item.field_count }}</span>
</div>
<div class="info-row">
<span class="info-label">作付面積合計:</span>
<span>{{ item.total_area|floatformat:4 }} 反</span>
</div>
{% if item.crops %}
<table class="crop-table">
<thead> <thead>
<tr> <tr>
<th>作物</th> <th>漢字地名</th>
<th>耕地-分筆</th>
<th>本地面積(m2)</th>
<th>作付品目</th>
<th>品種</th> <th>品種</th>
<th>圃場名称</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for crop in item.crops %} {% for row in rows %}
<tr> <tr>
<td>{{ crop.name }}</td> <td>{{ row.kanji_name }}</td>
<td>{{ crop.variety }}</td> <td>{{ row.k_s_num }}</td>
<td class="num">{{ row.area|default:"—" }}</td>
<td>{% if row.crop %}{{ row.crop }}{% else %}<span class="empty"></span>{% endif %}</td>
<td>{% if row.variety %}{{ row.variety }}{% else %}<span class="empty"></span>{% endif %}</td>
<td>{% if row.field_name %}{{ row.field_name }}{% else %}<span class="empty"></span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% endfor %}
</body> </body>
</html> </html>

View File

@@ -1,42 +1,62 @@
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.http import HttpResponse from django.http import HttpResponse
from weasyprint import HTML from weasyprint import HTML
from apps.fields.models import OfficialKyosaiField, OfficialChusankanField, Field from apps.fields.models import OfficialKyosaiField, OfficialChusankanField
from apps.plans.models import Plan from apps.plans.models import Plan
def generate_kyosai_pdf(request, year): def _get_plan_info(related_fields, year):
kyosai_fields = OfficialKyosaiField.objects.all() """共済/中山間区画に紐づく圃場群から作付け情報を集約する"""
plans = Plan.objects.filter(
field__in=related_fields, year=year
).select_related('crop', 'variety', 'field')
data = [] crop_names = []
variety_names = []
field_names = []
for plan in plans:
crop_names.append(plan.crop.name if plan.crop else '未設定')
variety_names.append(plan.variety.name if plan.variety else '')
field_names.append(plan.field.name)
if not related_fields.exists():
return {'crop': '', 'variety': '', 'field_name': ''}
# 紐づく圃場はあるがPlanがない場合
if not crop_names:
names = [f.name for f in related_fields]
return {'crop': '未設定', 'variety': '', 'field_name': ', '.join(names)}
return {
'crop': ', '.join(crop_names),
'variety': ', '.join(v for v in variety_names if v),
'field_name': ', '.join(field_names),
}
def generate_kyosai_pdf(request, year):
kyosai_fields = OfficialKyosaiField.objects.all().prefetch_related(
'fields', 'fields__plans', 'fields__plans__crop', 'fields__plans__variety'
).order_by('k_num', 's_num')
rows = []
for kyosai in kyosai_fields: for kyosai in kyosai_fields:
related_fields = kyosai.fields.all() related_fields = kyosai.fields.all()
plans = Plan.objects.filter(field__in=related_fields, year=year) info = _get_plan_info(related_fields, year)
crops = {} rows.append({
total_area = 0 'kanji_name': kyosai.kanji_name,
for plan in plans: 'k_s_num': f"{kyosai.k_num}-{kyosai.s_num}" if kyosai.s_num else kyosai.k_num,
crop_name = plan.crop.name if plan.crop else '未設定' 'area': kyosai.area,
if crop_name not in crops: 'crop': info['crop'],
crops[crop_name] = { 'variety': info['variety'],
'name': crop_name, 'field_name': info['field_name'],
'variety': plan.variety.name if plan.variety else '',
'count': 0
}
crops[crop_name]['count'] += 1
total_area += float(plan.field.area_tan)
data.append({
'kyosai': kyosai,
'fields': related_fields,
'crops': list(crops.values()),
'total_area': total_area,
'field_count': related_fields.count()
}) })
html_string = render_to_string('reports/kyosai_template.html', { html_string = render_to_string('reports/kyosai_template.html', {
'year': year, 'year': year,
'data': data 'rows': rows,
}) })
pdf = HTML(string=html_string).write_pdf() pdf = HTML(string=html_string).write_pdf()
@@ -47,37 +67,35 @@ def generate_kyosai_pdf(request, year):
def generate_chusankan_pdf(request, year): def generate_chusankan_pdf(request, year):
chusankan_fields = OfficialChusankanField.objects.all() chusankan_fields = OfficialChusankanField.objects.all().prefetch_related(
'fields', 'fields__plans', 'fields__plans__crop', 'fields__plans__variety'
).order_by('c_id')
data = [] rows = []
for chusankan in chusankan_fields: for ch in chusankan_fields:
related_fields = chusankan.fields.all() related_fields = ch.fields.all()
plans = Plan.objects.filter(field__in=related_fields, year=year) info = _get_plan_info(related_fields, year)
crops = {} # 所在地: 大字 + 字 + 地番 + 枝番
total_area = 0 location_parts = [ch.oaza, ch.aza, ch.chiban]
for plan in plans: if ch.branch_num and ch.branch_num != '-':
crop_name = plan.crop.name if plan.crop else '未設定' location_parts.append(ch.branch_num)
if crop_name not in crops: location = ' '.join(p for p in location_parts if p)
crops[crop_name] = {
'name': crop_name,
'variety': plan.variety.name if plan.variety else '',
'count': 0
}
crops[crop_name]['count'] += 1
total_area += float(plan.field.area_tan)
data.append({ rows.append({
'chusankan': chusankan, 'location': location,
'fields': related_fields, 'planting_area': ch.planting_area,
'crops': list(crops.values()), 'original_crop': ch.original_crop or '',
'total_area': total_area, 'manager': ch.manager or '',
'field_count': related_fields.count() 'owner': ch.owner or '',
'crop': info['crop'],
'variety': info['variety'],
'field_name': info['field_name'],
}) })
html_string = render_to_string('reports/chusankan_template.html', { html_string = render_to_string('reports/chusankan_template.html', {
'year': year, 'year': year,
'data': data 'rows': rows,
}) })
pdf = HTML(string=html_string).write_pdf() pdf = HTML(string=html_string).write_pdf()

View File

@@ -0,0 +1,199 @@
import { test, expect, Page } from '@playwright/test';
const LOGIN_URL = 'http://localhost:3000/login';
const API_URL = 'http://localhost:8000/api';
const USERNAME = 'admin';
const PASSWORD = 'admin123';
/** ログインしてトークンを localStorage にセット */
async function loginViaUI(page: Page) {
await page.goto(LOGIN_URL);
await page.fill('#username', USERNAME);
await page.fill('#password', PASSWORD);
await page.click('button[type="submit"]');
// ログイン後 /allocation にリダイレクトされるのを待つ
await page.waitForURL('**/allocation', { timeout: 15000 });
}
// ===== D-4: IsAuthenticated が有効か =====
test.describe('D-4: IsAuthenticated', () => {
test('未認証で API にアクセスすると 401 が返る', async ({ request }) => {
const res = await request.get(`${API_URL}/fields/`);
expect(res.status()).toBe(401);
});
test('未認証でフロントにアクセスするとログイン画面にリダイレクト', async ({ page }) => {
await page.goto('http://localhost:3000/allocation');
// API が 401 を返すので、フロントがログイン画面に遷移するはず
await page.waitForURL('**/login', { timeout: 10000 });
await expect(page.locator('text=KeinaSystem')).toBeVisible();
});
});
// ===== ログイン → 作付計画画面 =====
test.describe('ログイン → 作付計画画面', () => {
test('ログインして作付計画画面が表示される', async ({ page }) => {
await loginViaUI(page);
// allocation ページにいることを確認
expect(page.url()).toContain('/allocation');
// 圃場のデータが読み込まれるのを待つ(テーブルまたはリストが表示されるはず)
await page.waitForTimeout(2000);
// ページ内に何かしらのコンテンツがある
const body = await page.textContent('body');
expect(body).toBeTruthy();
});
});
// ===== A-8: 圃場詳細の共済/中山間情報表示 =====
test.describe('A-8: 圃場詳細 共済/中山間情報', () => {
test('共済・中山間の両方が紐づく圃場で情報が表示される', async ({ page }) => {
await loginViaUI(page);
// Field ID 8 (口神 ハウス南) は kyosai=1, chusankan=1
await page.goto('http://localhost:3000/fields/8');
await page.waitForLoadState('networkidle');
// 共済情報セクション
await expect(page.locator('text=共済情報')).toBeVisible();
// テーブルヘッダが表示される
await expect(page.locator('text=耕地-分筆')).toBeVisible();
await expect(page.locator('text=漢字地名')).toBeVisible();
// 「紐づけられた共済区画はありません」が表示されないこと
await expect(page.locator('text=紐づけられた共済区画はありません')).not.toBeVisible();
// 中山間情報セクション
await expect(page.locator('text=中山間情報')).toBeVisible();
await expect(page.locator('text=所在地')).toBeVisible();
// 「紐づけられた中山間区画はありません」が表示されないこと
await expect(page.locator('text=紐づけられた中山間区画はありません')).not.toBeVisible();
});
test('共済のみ紐づく圃場で共済情報が表示される', async ({ page }) => {
await loginViaUI(page);
// Field ID 3 (おまけ) は kyosai=1, chusankan=0
await page.goto('http://localhost:3000/fields/3');
await page.waitForLoadState('networkidle');
await expect(page.locator('text=共済情報')).toBeVisible();
await expect(page.locator('text=紐づけられた共済区画はありません')).not.toBeVisible();
await expect(page.locator('text=紐づけられた中山間区画はありません')).toBeVisible();
});
});
// ===== E-1: PDF帳票フォーマット再設計 =====
test.describe('E-1: PDF帳票', () => {
test('共済PDF が 200 で返る(表形式テンプレート)', async ({ page }) => {
await loginViaUI(page);
const token = await page.evaluate(() => localStorage.getItem('accessToken'));
const res = await page.request.get(`${API_URL}/reports/kyosai/2025/`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/pdf');
});
test('中山間PDF が 200 で返る(表形式テンプレート)', async ({ page }) => {
await loginViaUI(page);
const token = await page.evaluate(() => localStorage.getItem('accessToken'));
const res = await page.request.get(`${API_URL}/reports/chusankan/2025/`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/pdf');
});
test('帳票画面からPDFダウンロードリンクが表示される', async ({ page }) => {
await loginViaUI(page);
await page.goto('http://localhost:3000/reports');
await page.waitForLoadState('networkidle');
// 帳票画面が表示される
const body = await page.textContent('body');
expect(body).toBeTruthy();
});
});
// ===== E-1c: 中山間マスタ 17フィールド =====
test.describe('E-1c: 中山間マスタ拡張', () => {
test('中山間 API が 17 フィールドを返す', async ({ page }) => {
await loginViaUI(page);
const token = await page.evaluate(() => localStorage.getItem('accessToken'));
// Field ID 8 は chusankan=1
const res = await page.request.get(`${API_URL}/fields/8/`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const data = await res.json();
const ch = data.chusankan_fields[0];
expect(ch).toBeDefined();
// 新フィールドが存在する
expect(ch).toHaveProperty('manager');
expect(ch).toHaveProperty('owner');
expect(ch).toHaveProperty('planting_area');
expect(ch).toHaveProperty('original_crop');
expect(ch).toHaveProperty('slope');
expect(ch).toHaveProperty('base_amount');
expect(ch).toHaveProperty('branch_num');
expect(ch).toHaveProperty('land_type');
});
test('共済マスタの面積が 0 でない', async ({ page }) => {
await loginViaUI(page);
const token = await page.evaluate(() => localStorage.getItem('accessToken'));
// Field ID 3 (おまけ) は kyosai=1
const res = await page.request.get(`${API_URL}/fields/3/`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
const k = data.kyosai_fields[0];
expect(k).toBeDefined();
expect(k.area).toBeGreaterThan(0);
});
});
// ===== C-2: 共済マスタ unique 制約 =====
test.describe('C-2: 共済マスタ unique 制約 (k_num, s_num)', () => {
test('同じ k_num + s_num の重複登録が拒否される', async ({ page }) => {
await loginViaUI(page);
const token = await page.evaluate(() => localStorage.getItem('accessToken'));
// まずテスト用データを作成
const createRes = await page.request.post(`${API_URL}/fields/kyosai/`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
k_num: 'TEST99',
s_num: '1',
kanji_name: 'テスト区画',
address: 'テスト住所',
area: 1000,
},
});
// 作成成功 or 既に存在する場合
if (createRes.status() === 201) {
// 同じ k_num + s_num で再度作成 → 拒否されるべき
const dupRes = await page.request.post(`${API_URL}/fields/kyosai/`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
k_num: 'TEST99',
s_num: '1',
kanji_name: 'テスト区画2',
address: 'テスト住所2',
area: 2000,
},
});
// 400 (validation error) が返るべき
expect(dupRes.status()).toBe(400);
// クリーンアップ: テストデータ削除
const created = await createRes.json();
await page.request.delete(`${API_URL}/fields/kyosai/${created.id}/`, {
headers: { Authorization: `Bearer ${token}` },
});
}
});
});

View File

@@ -17,6 +17,7 @@
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
@@ -494,6 +495,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -558,6 +575,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -989,6 +1007,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1445,6 +1464,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2122,6 +2142,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -2290,6 +2311,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -3707,6 +3729,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -4461,6 +4484,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -4491,6 +4561,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4698,6 +4769,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -4710,6 +4782,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5617,6 +5690,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5786,6 +5860,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@@ -18,6 +18,7 @@
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",

View File

@@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
use: {
baseURL: 'http://localhost:3000',
headless: true,
},
webServer: undefined, // frontend/backend are already running in Docker
});

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}