Add plan variety change tracking

This commit is contained in:
akira
2026-04-05 16:32:57 +09:00
parent 5a9b6a053b
commit 21fb2323eb
6 changed files with 258 additions and 13 deletions

View File

@@ -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'],
},
),
]

View File

@@ -65,6 +65,51 @@ class Plan(models.Model):
return f"{self.field.name} - {self.year} - {self.crop.name}" 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): class RiceTransplantPlan(models.Model):
name = models.CharField(max_length=200, verbose_name='計画名') name = models.CharField(max_length=200, verbose_name='計画名')
year = models.IntegerField(verbose_name='年度') year = models.IntegerField(verbose_name='年度')

View File

@@ -5,6 +5,7 @@ from apps.fields.models import Field
from apps.materials.models import StockTransaction from apps.materials.models import StockTransaction
from .models import Crop, Variety, Plan from .models import Crop, Variety, Plan
from .models import RiceTransplantEntry, RiceTransplantPlan from .models import RiceTransplantEntry, RiceTransplantPlan
from .services import NO_CHANGE, update_plan_with_variety_tracking
class VarietySerializer(serializers.ModelSerializer): class VarietySerializer(serializers.ModelSerializer):
@@ -44,10 +45,12 @@ class PlanSerializer(serializers.ModelSerializer):
return Plan.objects.create(**validated_data) return Plan.objects.create(**validated_data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
for attr, value in validated_data.items(): return update_plan_with_variety_tracking(
setattr(instance, attr, value) instance,
instance.save() crop=validated_data.get('crop', NO_CHANGE),
return instance variety=validated_data.get('variety', NO_CHANGE),
notes=validated_data.get('notes', NO_CHANGE),
)
class RiceTransplantEntrySerializer(serializers.ModelSerializer): class RiceTransplantEntrySerializer(serializers.ModelSerializer):

View File

@@ -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)

View File

@@ -1,3 +1,92 @@
from django.contrib.auth import get_user_model
from django.test import TestCase 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)

View File

@@ -10,6 +10,7 @@ from .serializers import (
RiceTransplantPlanSerializer, RiceTransplantPlanSerializer,
RiceTransplantPlanWriteSerializer, RiceTransplantPlanWriteSerializer,
) )
from .services import update_plan_with_variety_tracking
from apps.fields.models import Field from apps.fields.models import Field
@@ -120,14 +121,22 @@ class PlanViewSet(viewsets.ModelViewSet):
updated = 0 updated = 0
created = 0 created = 0
for field_id in field_ids: for field_id in field_ids:
plan, was_created = Plan.objects.update_or_create( plan = Plan.objects.filter(field_id=field_id, year=year).first()
if plan is None:
Plan.objects.create(
field_id=field_id, field_id=field_id,
year=year, year=year,
defaults={'crop': crop, 'variety': variety} crop=crop,
variety=variety,
) )
if was_created:
created += 1 created += 1
else: continue
update_plan_with_variety_tracking(
plan,
crop=crop,
variety=variety,
)
updated += 1 updated += 1
return Response({'created': created, 'updated': updated, 'total': created + updated}) return Response({'created': created, 'updated': updated, 'total': created + updated})