From d70b5ee551a2a5ebc710ccbe41ccba95184fdad3 Mon Sep 17 00:00:00 2001 From: Akira Date: Tue, 17 Feb 2026 15:27:14 +0900 Subject: [PATCH] =?UTF-8?q?E-1=20=E5=AE=8C=E4=BA=86=E3=82=B5=E3=83=9E?= =?UTF-8?q?=E3=83=AA=E3=83=BC=20=E5=AE=9F=E6=96=BD=E5=86=85=E5=AE=B9=20#?= =?UTF-8?q?=09=E5=A4=89=E6=9B=B4=E5=86=85=E5=AE=B9=09=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=201=09OfficialChusankanField=20=E3=81=AB=201?= =?UTF-8?q?1=20=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=EF=BC=8817=E5=88=97=E5=8C=96=EF=BC=89=09models.py=202?= =?UTF-8?q?=09=E4=B8=AD=E5=B1=B1=E9=96=93=E3=82=A4=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88:=2017=20=E5=88=97=E3=81=99=E3=81=B9=E3=81=A6?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E5=AF=BE=E5=BF=9C=09views.?= =?UTF-8?q?py=203=09=E5=85=B1=E6=B8=88=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88:=20=E9=9D=A2=E7=A9=8D=E3=82=AB=E3=83=A9=E3=83=A0?= =?UTF-8?q?=E5=90=8D=E4=B8=8D=E4=B8=80=E8=87=B4=E3=83=90=E3=82=B0=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20+=20a=E2=86=92m2=20=E5=A4=89=E6=8F=9B(=C3=97100)=09?= =?UTF-8?q?views.py=204=09=E3=82=B7=E3=83=AA=E3=82=A2=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=B6=E3=81=AB=2011=20=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=83=89=E8=BF=BD=E5=8A=A0=09serializers.py=205=09=E5=85=B1?= =?UTF-8?q?=E6=B8=88=20PDF:=20A4=20=E7=B8=A6=E3=80=81=E8=A1=A8=E5=BD=A2?= =?UTF-8?q?=E5=BC=8F=E3=80=81@page=20=E8=A8=AD=E5=AE=9A=E3=80=81=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E7=95=AA=E5=8F=B7=E3=80=81=E4=B8=AD=E5=9B=BD?= =?UTF-8?q?=E8=AA=9E=E9=99=A4=E5=8E=BB=09kyosai=5Ftemplate.html=206=09?= =?UTF-8?q?=E4=B8=AD=E5=B1=B1=E9=96=93=20PDF:=20A4=20=E6=A8=AA=E3=80=81?= =?UTF-8?q?=E8=A1=A8=E5=BD=A2=E5=BC=8F=E3=80=81@page=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=80=81=E3=83=9A=E3=83=BC=E3=82=B8=E7=95=AA=E5=8F=B7=E3=80=81?= =?UTF-8?q?=E4=B8=AD=E5=9B=BD=E8=AA=9E=E9=99=A4=E5=8E=BB=09chusankan=5Ftem?= =?UTF-8?q?plate.html=207=09PDF=20=E7=94=9F=E6=88=90=E3=83=AD=E3=82=B8?= =?UTF-8?q?=E3=83=83=E3=82=AF:=20=E3=83=95=E3=83=A9=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E3=80=81null=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E3=80=81prefetch=5Frelated=09reports/views.py=208=09?= =?UTF-8?q?=E6=97=A2=E5=AD=98=E3=83=87=E3=83=BC=E3=82=BF=E5=86=8D=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=EF=BC=88=E5=85=B1=E6=B8=88?= =?UTF-8?q?=E9=9D=A2=E7=A9=8D=E4=BF=AE=E6=AD=A3=20+=20=E4=B8=AD=E5=B1=B1?= =?UTF-8?q?=E9=96=93=2017=20=E5=88=97=E5=9F=8B=E3=82=81=EF=BC=89=09?= =?UTF-8?q?=E2=80=94=209=09Playwright=20E2E=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=2011=20=E4=BB=B6=E5=85=A8=20PASS=09verify-fixes.spec.ts=20?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E7=99=BA=E8=A6=8B=E3=83=BB=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=83=90=E3=82=B0=20=E5=85=B1=E6=B8=88=20ODS?= =?UTF-8?q?=20=E3=81=AE=20=E6=9C=AC=E5=9C=B0=E9=9D=A2=E7=A9=8D=20(m2)=20?= =?UTF-8?q?=E3=82=AB=E3=83=A9=E3=83=A0=E5=90=8D=E3=81=AB=E3=82=B9=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=8C=E5=90=AB=E3=81=BE=E3=82=8C=E3=80=81?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E6=99=82=E3=81=AB?= =?UTF-8?q?=E9=9D=A2=E7=A9=8D=E3=81=8C=E5=85=A8=E4=BB=B6=200=20=E3=81=AB?= =?UTF-8?q?=E3=81=AA=E3=81=A3=E3=81=A6=E3=81=84=E3=81=9F=20=E9=9D=A2?= =?UTF-8?q?=E7=A9=8D=E3=81=AE=E5=8D=98=E4=BD=8D=E3=81=8C=E3=82=A2=E3=83=BC?= =?UTF-8?q?=E3=83=AB(a)=E3=81=A7=E3=81=82=E3=82=8B=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=8C=E5=88=A4=E6=98=8E=E3=80=82m2=20=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=20(=C3=97100)=20=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20PDF=20=E3=81=AF=20http://localhost:3000/reports=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=83=80=E3=82=A6=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=97=E3=81=A6=E7=A2=BA=E8=AA=8D=E3=81=A7=E3=81=8D=E3=81=BE?= =?UTF-8?q?=E3=81=99=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 +- .../0006_e1c_chusankan_17_fields.py | 78 +++++++ backend/apps/fields/models.py | 15 +- backend/apps/fields/serializers.py | 5 +- backend/apps/fields/views.py | 63 ++++-- .../templates/reports/chusankan_template.html | 109 ++++------ .../templates/reports/kyosai_template.html | 93 ++++---- backend/apps/reports/views.py | 116 +++++----- frontend/e2e/verify-fixes.spec.ts | 199 ++++++++++++++++++ frontend/package-lock.json | 75 +++++++ frontend/package.json | 1 + frontend/playwright.config.ts | 11 + frontend/test-results/.last-run.json | 4 + 13 files changed, 584 insertions(+), 192 deletions(-) create mode 100644 backend/apps/fields/migrations/0006_e1c_chusankan_17_fields.py create mode 100644 frontend/e2e/verify-fixes.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/test-results/.last-run.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d0d1fec..e7909f2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,12 @@ "Bash(curl:*)", "Bash(npm 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:*)" ] } } diff --git a/backend/apps/fields/migrations/0006_e1c_chusankan_17_fields.py b/backend/apps/fields/migrations/0006_e1c_chusankan_17_fields.py new file mode 100644 index 0000000..82eb281 --- /dev/null +++ b/backend/apps/fields/migrations/0006_e1c_chusankan_17_fields.py @@ -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='交付金額'), + ), + ] diff --git a/backend/apps/fields/models.py b/backend/apps/fields/models.py index 9041400..67fd452 100644 --- a/backend/apps/fields/models.py +++ b/backend/apps/fields/models.py @@ -20,11 +20,22 @@ class OfficialKyosaiField(models.Model): class OfficialChusankanField(models.Model): 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="大字") aza = models.CharField(max_length=100, verbose_name="字") chiban = models.CharField(max_length=50, verbose_name="地番") - area = models.IntegerField(default=0, verbose_name="面積(m2)") - payment_amount = models.IntegerField(blank=True, null=True, verbose_name="支払金額") + branch_num = models.CharField(max_length=20, 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: verbose_name = "中山間マスタ" diff --git a/backend/apps/fields/serializers.py b/backend/apps/fields/serializers.py index df01093..3e21947 100644 --- a/backend/apps/fields/serializers.py +++ b/backend/apps/fields/serializers.py @@ -11,7 +11,10 @@ class OfficialKyosaiFieldSerializer(serializers.ModelSerializer): class OfficialChusankanFieldSerializer(serializers.ModelSerializer): class Meta: 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): diff --git a/backend/apps/fields/views.py b/backend/apps/fields/views.py index 473eb43..0ac1797 100644 --- a/backend/apps/fields/views.py +++ b/backend/apps/fields/views.py @@ -32,21 +32,33 @@ def import_kyosai_master(request): try: df = pd.read_excel(ods_file, engine='odf') 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 updated_count = 0 for _, row in df.iterrows(): k_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: 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 = { 'address': 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( @@ -169,37 +181,50 @@ def import_chusankan_master(request): 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: df = pd.read_excel(ods_file, engine='odf') df.columns = df.columns.str.strip() - + created_count = 0 updated_count = 0 for _, row in df.iterrows(): raw_id = row.get('ID') c_id = str(raw_id).strip() if pd.notna(raw_id) else '' - + if not c_id: continue if not any(char.isdigit() for char in c_id): 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 = { - 'oaza': str(row.get('大字', '')).strip() if pd.notna(row.get('大字')) else '', - 'aza': str(row.get('字', '')).strip() if pd.notna(row.get('字')) else '', - 'chiban': str(row.get('地番', '')).strip() if pd.notna(row.get('地番')) else '', - 'area': int(float(row.get('農地面積', 0))) if pd.notna(row.get('農地面積')) else 0, - 'payment_amount': payment_amount, + 'chusankan_flag': safe_str(row.get('中山間')) or None, + 'oaza': safe_str(row.get('大字')), + 'aza': safe_str(row.get('字')), + 'chiban': safe_str(row.get('地番')), + '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( diff --git a/backend/apps/reports/templates/reports/chusankan_template.html b/backend/apps/reports/templates/reports/chusankan_template.html index 8955495..3b6ced3 100644 --- a/backend/apps/reports/templates/reports/chusankan_template.html +++ b/backend/apps/reports/templates/reports/chusankan_template.html @@ -2,107 +2,84 @@ - 中山間地域直接支払申请书 + 中山間地域等直接支払交付金({{ year }}年度) -

中山間地域直接支払申请书 - {{ year }}年度

- - {% for item in data %} -

{{ item.chusankan.c_id }} - {{ item.chusankan.oaza }}{{ item.chusankan.aza }}

- -
- 大字: - {{ item.chusankan.oaza }} -
-
- 字: - {{ item.chusankan.aza }} -
-
- 地番: - {{ item.chusankan.chiban }} -
-
- 面積: - {{ item.chusankan.area }} ha -
-
- 支払金額: - {{ item.chusankan.payment_amount|default:"-" }} 円 -
-
- 関連圃場数: - {{ item.field_count }} -
-
- 作付面積合計: - {{ item.total_area|floatformat:4 }} 反 -
- - {% if item.crops %} - +

中山間地域等直接支払交付金({{ year }}年度)

+ +
+ + + + + + - {% for crop in item.crops %} + {% for row in rows %} - - + + + + + + + + {% endfor %}
所在地植栽面積(m2)作付品目(元)協定管理者所有者 作物 品種圃場名称
{{ crop.name }}{{ crop.variety }}{{ row.location }}{{ row.planting_area|default:"—" }}{{ row.original_crop|default:"—" }}{{ row.manager|default:"—" }}{{ row.owner|default:"—" }}{% if row.crop %}{{ row.crop }}{% else %}{% endif %}{% if row.variety %}{{ row.variety }}{% else %}{% endif %}{% if row.field_name %}{{ row.field_name }}{% else %}{% endif %}
- {% endif %} - - {% endfor %} diff --git a/backend/apps/reports/templates/reports/kyosai_template.html b/backend/apps/reports/templates/reports/kyosai_template.html index eefb1bd..159a356 100644 --- a/backend/apps/reports/templates/reports/kyosai_template.html +++ b/backend/apps/reports/templates/reports/kyosai_template.html @@ -2,95 +2,80 @@ - 水稲共済申请书 + 水稲共済細目書({{ year }}年度) -

水稲共済申请书 - {{ year }}年度

- - {% for item in data %} -

{{ item.kyosai.k_num }} - {{ item.kyosai.kanji_name }}

- -
- 住所: - {{ item.kyosai.address }} -
-
- 面積: - {{ item.kyosai.area }} ha -
-
- 関連圃場数: - {{ item.field_count }} -
-
- 作付面積合計: - {{ item.total_area|floatformat:4 }} 反 -
- - {% if item.crops %} - +

水稲共済細目書({{ year }}年度)

+ +
- + + + + + - {% for crop in item.crops %} + {% for row in rows %} - - + + + + + + {% endfor %}
作物漢字地名耕地-分筆本地面積(m2)作付品目 品種圃場名称
{{ crop.name }}{{ crop.variety }}{{ row.kanji_name }}{{ row.k_s_num }}{{ row.area|default:"—" }}{% if row.crop %}{{ row.crop }}{% else %}{% endif %}{% if row.variety %}{{ row.variety }}{% else %}{% endif %}{% if row.field_name %}{{ row.field_name }}{% else %}{% endif %}
- {% endif %} - - {% endfor %} diff --git a/backend/apps/reports/views.py b/backend/apps/reports/views.py index 0d2669c..5219693 100644 --- a/backend/apps/reports/views.py +++ b/backend/apps/reports/views.py @@ -1,42 +1,62 @@ from django.template.loader import render_to_string from django.http import HttpResponse 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 -def generate_kyosai_pdf(request, year): - kyosai_fields = OfficialKyosaiField.objects.all() +def _get_plan_info(related_fields, year): + """共済/中山間区画に紐づく圃場群から作付け情報を集約する""" + 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: related_fields = kyosai.fields.all() - plans = Plan.objects.filter(field__in=related_fields, year=year) + info = _get_plan_info(related_fields, year) - crops = {} - total_area = 0 - for plan in plans: - crop_name = plan.crop.name if plan.crop else '未設定' - if crop_name not in crops: - 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({ - 'kyosai': kyosai, - 'fields': related_fields, - 'crops': list(crops.values()), - 'total_area': total_area, - 'field_count': related_fields.count() + rows.append({ + 'kanji_name': kyosai.kanji_name, + 'k_s_num': f"{kyosai.k_num}-{kyosai.s_num}" if kyosai.s_num else kyosai.k_num, + 'area': kyosai.area, + 'crop': info['crop'], + 'variety': info['variety'], + 'field_name': info['field_name'], }) html_string = render_to_string('reports/kyosai_template.html', { 'year': year, - 'data': data + 'rows': rows, }) pdf = HTML(string=html_string).write_pdf() @@ -47,37 +67,35 @@ def generate_kyosai_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 = [] - for chusankan in chusankan_fields: - related_fields = chusankan.fields.all() - plans = Plan.objects.filter(field__in=related_fields, year=year) + rows = [] + for ch in chusankan_fields: + related_fields = ch.fields.all() + info = _get_plan_info(related_fields, year) - crops = {} - total_area = 0 - for plan in plans: - crop_name = plan.crop.name if plan.crop else '未設定' - if crop_name not in crops: - 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) + # 所在地: 大字 + 字 + 地番 + 枝番 + location_parts = [ch.oaza, ch.aza, ch.chiban] + if ch.branch_num and ch.branch_num != '-': + location_parts.append(ch.branch_num) + location = ' '.join(p for p in location_parts if p) - data.append({ - 'chusankan': chusankan, - 'fields': related_fields, - 'crops': list(crops.values()), - 'total_area': total_area, - 'field_count': related_fields.count() + rows.append({ + 'location': location, + 'planting_area': ch.planting_area, + 'original_crop': ch.original_crop or '', + 'manager': ch.manager or '', + 'owner': ch.owner or '', + 'crop': info['crop'], + 'variety': info['variety'], + 'field_name': info['field_name'], }) html_string = render_to_string('reports/chusankan_template.html', { 'year': year, - 'data': data + 'rows': rows, }) pdf = HTML(string=html_string).write_pdf() diff --git a/frontend/e2e/verify-fixes.spec.ts b/frontend/e2e/verify-fixes.spec.ts new file mode 100644 index 0000000..39fa355 --- /dev/null +++ b/frontend/e2e/verify-fixes.spec.ts @@ -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}` }, + }); + } + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5263b69..10091ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "tailwind-merge": "^3.4.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -494,6 +495,22 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -558,6 +575,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -989,6 +1007,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1445,6 +1464,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "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.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2290,6 +2311,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3707,6 +3729,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4461,6 +4484,53 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4491,6 +4561,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4698,6 +4769,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4710,6 +4782,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5617,6 +5690,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5786,6 +5860,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index d77e32e..599ae7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "tailwind-merge": "^3.4.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..36deb27 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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 +}); diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file