分配計画機能を実装

施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を
表示・PDF出力できる機能を追加。

- Backend: DistributionPlan/Group/GroupField モデル (migration 0003)
- API: GET/POST/PUT/DELETE/PDF (/api/fertilizer/distribution/)
- Frontend: 一覧・新規作成・編集画面 (/distribution)
- Navbar に分配計画メニューを追加
- 集計プレビューはクライアントサイド計算(API不要)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-03-02 09:43:20 +09:00
parent 0d321df1c4
commit 466eef128c
15 changed files with 1656 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
@admin.register(Fertilizer)
@@ -17,3 +17,27 @@ class FertilizationPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'variety']
list_filter = ['year']
inlines = [FertilizationEntryInline]
class DistributionGroupFieldInline(admin.TabularInline):
model = DistributionGroupField
extra = 0
readonly_fields = ['distribution_plan']
class DistributionGroupInline(admin.TabularInline):
model = DistributionGroup
extra = 0
@admin.register(DistributionPlan)
class DistributionPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'fertilization_plan', 'created_at']
list_filter = ['fertilization_plan__year']
inlines = [DistributionGroupInline]
@admin.register(DistributionGroup)
class DistributionGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'distribution_plan', 'order']
inlines = [DistributionGroupFieldInline]

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.0 on 2026-03-01 15:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0002_alter_fertilizationentry_fertilizer'),
('fields', '0006_e1c_chusankan_17_fields'),
]
operations = [
migrations.CreateModel(
name='DistributionPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('fertilization_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='distribution_plans', to='fertilizer.fertilizationplan', verbose_name='施肥計画')),
],
options={
'verbose_name': '分配計画',
'verbose_name_plural': '分配計画',
'ordering': ['-fertilization_plan__year', 'name'],
},
),
migrations.CreateModel(
name='DistributionGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='グループ名')),
('order', models.PositiveIntegerField(default=0, verbose_name='表示順')),
('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.distributionplan', verbose_name='分配計画')),
],
options={
'verbose_name': '分配グループ',
'verbose_name_plural': '分配グループ',
'ordering': ['order', 'id'],
'unique_together': {('distribution_plan', 'name')},
},
),
migrations.CreateModel(
name='DistributionGroupField',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_assignments', to='fertilizer.distributiongroup', verbose_name='グループ')),
('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.distributionplan', verbose_name='分配計画')),
],
options={
'verbose_name': 'グループ圃場割り当て',
'verbose_name_plural': 'グループ圃場割り当て',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('distribution_plan', 'field')},
},
),
]

View File

@@ -67,3 +67,64 @@ class FertilizationEntry(models.Model):
def __str__(self):
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}"
class DistributionPlan(models.Model):
"""分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する"""
fertilization_plan = models.ForeignKey(
FertilizationPlan, on_delete=models.CASCADE,
related_name='distribution_plans', verbose_name='施肥計画'
)
name = models.CharField(max_length=200, verbose_name='計画名')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '分配計画'
verbose_name_plural = '分配計画'
ordering = ['-fertilization_plan__year', 'name']
def __str__(self):
return f"{self.fertilization_plan.year} {self.name}"
class DistributionGroup(models.Model):
"""分配グループ:ある場所にまとめて置く圃場のグループ"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE,
related_name='groups', verbose_name='分配計画'
)
name = models.CharField(max_length=100, verbose_name='グループ名')
order = models.PositiveIntegerField(default=0, verbose_name='表示順')
class Meta:
verbose_name = '分配グループ'
verbose_name_plural = '分配グループ'
unique_together = [['distribution_plan', 'name']]
ordering = ['order', 'id']
def __str__(self):
return f"{self.distribution_plan} / {self.name}"
class DistributionGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1分配計画"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
)
group = models.ForeignKey(
DistributionGroup, on_delete=models.CASCADE,
related_name='field_assignments', verbose_name='グループ'
)
field = models.ForeignKey(
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
)
class Meta:
verbose_name = 'グループ圃場割り当て'
verbose_name_plural = 'グループ圃場割り当て'
unique_together = [['distribution_plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f"{self.group.name} / {self.field.name}"

View File

@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
class FertilizerSerializer(serializers.ModelSerializer):
@@ -79,3 +79,140 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
fertilizer_id=entry['fertilizer_id'],
bags=entry['bags'],
)
# ─── 分配計画 ────────────────────────────────────────────────────────────
class DistributionGroupFieldSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='field.id', read_only=True)
name = serializers.CharField(source='field.name', read_only=True)
area_tan = serializers.DecimalField(
source='field.area_tan', max_digits=6, decimal_places=4, read_only=True
)
class Meta:
model = DistributionGroupField
fields = ['id', 'name', 'area_tan']
class DistributionGroupReadSerializer(serializers.ModelSerializer):
fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class Meta:
model = DistributionGroup
fields = ['id', 'name', 'order', 'fields']
class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer):
"""分配計画詳細に埋め込む施肥計画情報肥料一覧・entries 含む)"""
variety_name = serializers.SerializerMethodField()
crop_name = serializers.SerializerMethodField()
fertilizers = serializers.SerializerMethodField()
entries = serializers.SerializerMethodField()
class Meta:
model = FertilizationPlan
fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries']
def get_variety_name(self, obj):
return obj.variety.name
def get_crop_name(self, obj):
return obj.variety.crop.name
def get_fertilizers(self, obj):
fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct()
from .models import Fertilizer as F
fertilizers = F.objects.filter(id__in=fert_ids).order_by('name')
return [{'id': f.id, 'name': f.name} for f in fertilizers]
def get_entries(self, obj):
return [
{'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)}
for e in obj.entries.all()
]
class DistributionPlanListSerializer(serializers.ModelSerializer):
fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True)
fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True)
year = serializers.IntegerField(source='fertilization_plan.year', read_only=True)
variety_name = serializers.SerializerMethodField()
crop_name = serializers.SerializerMethodField()
group_count = serializers.SerializerMethodField()
field_count = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
fields = [
'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
'created_at', 'updated_at',
]
def get_variety_name(self, obj):
return obj.fertilization_plan.variety.name
def get_crop_name(self, obj):
return obj.fertilization_plan.variety.crop.name
def get_group_count(self, obj):
return obj.groups.count()
def get_field_count(self, obj):
return obj.distributiongroupfield_set.count()
class DistributionPlanReadSerializer(serializers.ModelSerializer):
fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
groups = DistributionGroupReadSerializer(many=True, read_only=True)
unassigned_fields = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
def get_unassigned_fields(self, obj):
assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True)
plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct()
from apps.fields.models import Field as F
unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id')
return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned]
class DistributionPlanWriteSerializer(serializers.ModelSerializer):
fertilization_plan_id = serializers.IntegerField(write_only=True)
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan_id', 'groups']
def create(self, validated_data):
groups_data = validated_data.pop('groups', [])
plan = DistributionPlan.objects.create(**validated_data)
self._save_groups(plan, groups_data)
return plan
def update(self, instance, validated_data):
groups_data = validated_data.pop('groups', None)
instance.name = validated_data.get('name', instance.name)
instance.save()
if groups_data is not None:
instance.groups.all().delete()
self._save_groups(instance, groups_data)
return instance
def _save_groups(self, plan, groups_data):
for g_data in groups_data:
group = DistributionGroup.objects.create(
distribution_plan=plan,
name=g_data['name'],
order=g_data.get('order', 0),
)
for field_id in g_data.get('field_ids', []):
DistributionGroupField.objects.create(
distribution_plan=plan,
group=group,
field_id=field_id,
)

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 12mm; }
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
.subtitle { text-align: center; font-size: 9pt; color: #555; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 6px; }
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
th { background: #e8f5e9; text-align: center; }
.col-name { text-align: left; }
.group-row { font-weight: bold; background: #c8e6c9; }
.group-row td { font-size: 10pt; }
.group-star { color: #2e7d32; margin-right: 2px; }
.field-row td { font-size: 8.5pt; color: #444; background: #fafafa; }
.field-indent { padding-left: 14px; }
tr.total-row { font-weight: bold; background: #f5f5f5; }
.zero { color: #bbb; }
</style>
</head>
<body>
<h1>分配計画書</h1>
<p class="subtitle">
{{ fert_plan.year }}年度 {{ fert_plan.variety.crop.name }} / {{ fert_plan.variety.name }}
/施肥計画「{{ fert_plan.name }}」
/分配計画「{{ dist_plan.name }}」
</p>
<table>
<thead>
<tr>
<th class="col-name">グループ / 圃場</th>
{% for fert in fertilizers %}
<th>{{ fert.name }}<br><small>(袋)</small></th>
{% endfor %}
<th>合計袋数</th>
</tr>
</thead>
<tbody>
{% for group in group_rows %}
{# グループ合計行 #}
<tr class="group-row">
<td class="col-name"><span class="group-star"></span>{{ group.name }}</td>
{% for total in group.totals %}
<td>{% if total %}{{ total }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ group.row_total }}</td>
</tr>
{# 圃場サブ行 #}
{% for row in group.field_rows %}
<tr class="field-row">
<td class="col-name field-indent">{{ row.field.name }}{{ row.field.area_tan }}反)</td>
{% for cell in row.cells %}
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ row.total }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td class="col-name">合計</td>
{% for total in fert_totals %}
<td>{{ total }}</td>
{% endfor %}
<td>{{ grand_total }}</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
urlpatterns = [
path('', include(router.urls)),

View File

@@ -11,11 +11,14 @@ from weasyprint import HTML
from apps.fields.models import Field
from apps.plans.models import Plan, Variety
from .models import Fertilizer, FertilizationPlan
from .models import Fertilizer, FertilizationPlan, DistributionPlan
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
DistributionPlanListSerializer,
DistributionPlanReadSerializer,
DistributionPlanWriteSerializer,
)
@@ -194,3 +197,128 @@ class CalculateView(APIView):
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
return Response(results)
class DistributionPlanViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = DistributionPlan.objects.select_related(
'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop'
).prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
'fertilization_plan__entries', 'fertilization_plan__entries__field',
'fertilization_plan__entries__fertilizer',
'distributiongroupfield_set',
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(fertilization_plan__year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return DistributionPlanWriteSerializer
if self.action == 'list':
return DistributionPlanListSerializer
return DistributionPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
dist_plan = self.get_object()
fert_plan = dist_plan.fertilization_plan
# 施肥計画の肥料一覧(名前順)
fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
fertilizers = sorted(
Fertilizer.objects.filter(id__in=fert_ids),
key=lambda f: f.name
)
# entries を (field_id, fertilizer_id) → bags のマトリクスに変換
entry_map = {}
for e in fert_plan.entries.all():
entry_map[(e.field_id, e.fertilizer_id)] = e.bags
# グループ行の構築
groups = dist_plan.groups.prefetch_related('field_assignments__field').all()
group_rows = []
for group in groups:
fields_in_group = [
a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id')
]
# グループ合計(肥料ごと)
group_totals = []
for fert in fertilizers:
total = sum(
entry_map.get((f.id, fert.id), Decimal('0'))
for f in fields_in_group
)
group_totals.append(total)
group_row_total = sum(group_totals)
# 圃場サブ行
field_rows = []
for field in fields_in_group:
cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers]
row_total = sum(v for v in cells if v != '')
field_rows.append({'field': field, 'cells': cells, 'total': row_total})
group_rows.append({
'name': group.name,
'totals': group_totals,
'row_total': group_row_total,
'field_rows': field_rows,
})
# 未割り当て圃場
assigned_ids = dist_plan.distributiongroupfield_set.values_list('field_id', flat=True)
plan_field_ids = fert_plan.entries.values_list('field_id', flat=True).distinct()
unassigned_fields = Field.objects.filter(
id__in=plan_field_ids
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
unassigned_rows = []
if unassigned_fields.exists():
ua_totals = []
for fert in fertilizers:
total = sum(
entry_map.get((f.id, fert.id), Decimal('0'))
for f in unassigned_fields
)
ua_totals.append(total)
unassigned_rows = [{
'name': '未割り当て',
'totals': ua_totals,
'row_total': sum(ua_totals),
'field_rows': [
{
'field': f,
'cells': [entry_map.get((f.id, fert.id), '') for fert in fertilizers],
'total': sum(entry_map.get((f.id, fert.id), Decimal('0')) for fert in fertilizers),
}
for f in unassigned_fields
],
}]
all_group_rows = group_rows + unassigned_rows
fert_totals = [
sum(r['totals'][i] for r in all_group_rows)
for i in range(len(fertilizers))
]
context = {
'dist_plan': dist_plan,
'fert_plan': fert_plan,
'fertilizers': fertilizers,
'group_rows': all_group_rows,
'fert_totals': fert_totals,
'grand_total': sum(fert_totals),
}
html_string = render_to_string('fertilizer/distribution_pdf.html', context)
pdf_file = HTML(string=html_string).write_pdf()
response = HttpResponse(pdf_file, content_type='application/pdf')
response['Content-Disposition'] = (
f'attachment; filename="distribution_{fert_plan.year}_{dist_plan.id}.pdf"'
)
return response