diff --git a/backend/apps/plans/migrations/0010_planvarietychange.py b/backend/apps/plans/migrations/0010_planvarietychange.py new file mode 100644 index 0000000..97607d0 --- /dev/null +++ b/backend/apps/plans/migrations/0010_planvarietychange.py @@ -0,0 +1,32 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fields', '0006_e1c_chusankan_17_fields'), + ('plans', '0009_alter_ricetransplantentry_installed_seedling_boxes'), + ] + + operations = [ + migrations.CreateModel( + name='PlanVarietyChange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('year', models.IntegerField(verbose_name='作付年度')), + ('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='変更日時')), + ('reason', models.TextField(blank=True, default='', verbose_name='変更理由')), + ('moved_entry_count', models.IntegerField(default=0, verbose_name='移動エントリ数')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='plan_variety_changes', to='fields.field', verbose_name='圃場')), + ('new_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='new_plan_variety_changes', to='plans.variety', verbose_name='変更後品種')), + ('old_variety', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='old_plan_variety_changes', to='plans.variety', verbose_name='変更前品種')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variety_changes', to='plans.plan', verbose_name='作付け計画')), + ], + options={ + 'verbose_name': '作付け計画品種変更履歴', + 'verbose_name_plural': '作付け計画品種変更履歴', + 'ordering': ['-changed_at', '-id'], + }, + ), + ] diff --git a/backend/apps/plans/models.py b/backend/apps/plans/models.py index a19c4b0..816c456 100644 --- a/backend/apps/plans/models.py +++ b/backend/apps/plans/models.py @@ -65,6 +65,51 @@ class Plan(models.Model): return f"{self.field.name} - {self.year} - {self.crop.name}" +class PlanVarietyChange(models.Model): + field = models.ForeignKey( + Field, + on_delete=models.PROTECT, + related_name='plan_variety_changes', + verbose_name='圃場', + ) + year = models.IntegerField(verbose_name='作付年度') + plan = models.ForeignKey( + Plan, + on_delete=models.CASCADE, + related_name='variety_changes', + verbose_name='作付け計画', + ) + changed_at = models.DateTimeField(auto_now_add=True, verbose_name='変更日時') + old_variety = models.ForeignKey( + Variety, + on_delete=models.SET_NULL, + related_name='old_plan_variety_changes', + verbose_name='変更前品種', + null=True, + blank=True, + ) + new_variety = models.ForeignKey( + Variety, + on_delete=models.SET_NULL, + related_name='new_plan_variety_changes', + verbose_name='変更後品種', + null=True, + blank=True, + ) + reason = models.TextField(blank=True, default='', verbose_name='変更理由') + moved_entry_count = models.IntegerField(default=0, verbose_name='移動エントリ数') + + class Meta: + verbose_name = '作付け計画品種変更履歴' + verbose_name_plural = '作付け計画品種変更履歴' + ordering = ['-changed_at', '-id'] + + def __str__(self): + old_name = self.old_variety.name if self.old_variety else '未設定' + new_name = self.new_variety.name if self.new_variety else '未設定' + return f'{self.field.name} {self.year}: {old_name} -> {new_name}' + + class RiceTransplantPlan(models.Model): name = models.CharField(max_length=200, verbose_name='計画名') year = models.IntegerField(verbose_name='年度') diff --git a/backend/apps/plans/serializers.py b/backend/apps/plans/serializers.py index b79f7f5..0ba1b78 100644 --- a/backend/apps/plans/serializers.py +++ b/backend/apps/plans/serializers.py @@ -5,6 +5,7 @@ from apps.fields.models import Field from apps.materials.models import StockTransaction from .models import Crop, Variety, Plan from .models import RiceTransplantEntry, RiceTransplantPlan +from .services import NO_CHANGE, update_plan_with_variety_tracking class VarietySerializer(serializers.ModelSerializer): @@ -44,10 +45,12 @@ class PlanSerializer(serializers.ModelSerializer): return Plan.objects.create(**validated_data) def update(self, instance, validated_data): - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - return instance + return update_plan_with_variety_tracking( + instance, + crop=validated_data.get('crop', NO_CHANGE), + variety=validated_data.get('variety', NO_CHANGE), + notes=validated_data.get('notes', NO_CHANGE), + ) class RiceTransplantEntrySerializer(serializers.ModelSerializer): diff --git a/backend/apps/plans/services.py b/backend/apps/plans/services.py new file mode 100644 index 0000000..96c2527 --- /dev/null +++ b/backend/apps/plans/services.py @@ -0,0 +1,67 @@ +from django.db import transaction + +from .models import Plan, PlanVarietyChange + + +class _NoChange: + pass + + +NO_CHANGE = _NoChange() + + +@transaction.atomic +def update_plan_with_variety_tracking( + plan: Plan, + *, + crop=NO_CHANGE, + variety=NO_CHANGE, + notes=NO_CHANGE, + reason: str = '', +): + old_variety = plan.variety + updated_fields = [] + + if crop is not NO_CHANGE: + plan.crop = crop + updated_fields.append('crop') + if variety is not NO_CHANGE: + plan.variety = variety + updated_fields.append('variety') + if notes is not NO_CHANGE: + plan.notes = notes + updated_fields.append('notes') + + if updated_fields: + plan.save(update_fields=updated_fields) + + if variety is not NO_CHANGE and _get_variety_id(old_variety) != _get_variety_id(plan.variety): + handle_plan_variety_change(plan, old_variety=old_variety, new_variety=plan.variety, reason=reason) + + return plan + + +@transaction.atomic +def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: str = ''): + if _get_variety_id(old_variety) == _get_variety_id(new_variety): + return None + + change = PlanVarietyChange.objects.create( + field=plan.field, + year=plan.year, + plan=plan, + old_variety=old_variety, + new_variety=new_variety, + reason=reason, + ) + process_plan_variety_change(change) + return change + + +def process_plan_variety_change(change: PlanVarietyChange): + """後続 issue で施肥計画・田植え計画への移動処理を追加する入口。""" + return change + + +def _get_variety_id(variety): + return getattr(variety, 'id', None) diff --git a/backend/apps/plans/tests.py b/backend/apps/plans/tests.py index 7ce503c..f6d1f44 100644 --- a/backend/apps/plans/tests.py +++ b/backend/apps/plans/tests.py @@ -1,3 +1,92 @@ +from django.contrib.auth import get_user_model from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate -# Create your tests here. +from apps.fields.models import Field +from .models import Crop, Plan, PlanVarietyChange, Variety +from .serializers import PlanSerializer +from .views import PlanViewSet + + +class PlanVarietyChangeTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username='tester', + password='secret12345', + ) + self.crop = Crop.objects.create(name='水稲') + self.old_variety = Variety.objects.create(crop=self.crop, name='にこまる') + self.new_variety = Variety.objects.create(crop=self.crop, name='たちはるか特栽') + self.field = Field.objects.create( + name='足川北上', + address='高知県高岡郡', + area_tan='1.2000', + area_m2=1200, + owner_name='吉田', + group_name='北', + display_order=1, + ) + self.plan = Plan.objects.create( + field=self.field, + year=2026, + crop=self.crop, + variety=self.old_variety, + notes='', + ) + + def test_serializer_update_creates_history_when_variety_changes(self): + serializer = PlanSerializer( + instance=self.plan, + data={'variety': self.new_variety.id}, + partial=True, + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + self.plan.refresh_from_db() + self.assertEqual(self.plan.variety_id, self.new_variety.id) + + change = PlanVarietyChange.objects.get(plan=self.plan) + self.assertEqual(change.field_id, self.field.id) + self.assertEqual(change.year, 2026) + self.assertEqual(change.old_variety_id, self.old_variety.id) + self.assertEqual(change.new_variety_id, self.new_variety.id) + self.assertEqual(change.moved_entry_count, 0) + + def test_serializer_update_does_not_create_history_without_variety_change(self): + serializer = PlanSerializer( + instance=self.plan, + data={'notes': 'メモ更新'}, + partial=True, + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + self.plan.refresh_from_db() + self.assertEqual(self.plan.notes, 'メモ更新') + self.assertFalse(PlanVarietyChange.objects.exists()) + + def test_bulk_update_creates_history_for_existing_plan(self): + view = PlanViewSet.as_view({'post': 'bulk_update'}) + request = self.factory.post( + '/api/plans/bulk_update/', + { + 'field_ids': [self.field.id], + 'year': 2026, + 'crop': self.crop.id, + 'variety': self.new_variety.id, + }, + format='json', + ) + force_authenticate(request, user=self.user) + + response = view(request) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertEqual(self.plan.variety_id, self.new_variety.id) + + change = PlanVarietyChange.objects.get(plan=self.plan) + self.assertEqual(change.old_variety_id, self.old_variety.id) + self.assertEqual(change.new_variety_id, self.new_variety.id) diff --git a/backend/apps/plans/views.py b/backend/apps/plans/views.py index 76d192d..62964dc 100644 --- a/backend/apps/plans/views.py +++ b/backend/apps/plans/views.py @@ -10,6 +10,7 @@ from .serializers import ( RiceTransplantPlanSerializer, RiceTransplantPlanWriteSerializer, ) +from .services import update_plan_with_variety_tracking from apps.fields.models import Field @@ -120,15 +121,23 @@ class PlanViewSet(viewsets.ModelViewSet): updated = 0 created = 0 for field_id in field_ids: - plan, was_created = Plan.objects.update_or_create( - field_id=field_id, - year=year, - defaults={'crop': crop, 'variety': variety} - ) - if was_created: + plan = Plan.objects.filter(field_id=field_id, year=year).first() + if plan is None: + Plan.objects.create( + field_id=field_id, + year=year, + crop=crop, + variety=variety, + ) created += 1 - else: - updated += 1 + continue + + update_plan_with_variety_tracking( + plan, + crop=crop, + variety=variety, + ) + updated += 1 return Response({'created': created, 'updated': updated, 'total': created + updated})