Add plan variety change tracking
This commit is contained in:
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal file
32
backend/apps/plans/migrations/0010_planvarietychange.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='年度')
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
67
backend/apps/plans/services.py
Normal file
67
backend/apps/plans/services.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
Reference in New Issue
Block a user