Compare commits
7 Commits
4299c6eb4b
...
c675b7b7ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c675b7b7ae | ||
|
|
ae0249be69 | ||
|
|
1d5bcc9dd6 | ||
|
|
98814299cf | ||
|
|
21fb2323eb | ||
|
|
5a9b6a053b | ||
|
|
429a98decb |
@@ -3,9 +3,10 @@ from decimal import Decimal
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from apps.materials.stock_service import create_reserves_for_plan
|
||||||
from apps.materials.models import StockTransaction
|
from apps.materials.models import StockTransaction
|
||||||
from apps.workrecords.services import sync_spreading_work_record
|
from apps.workrecords.services import sync_spreading_work_record
|
||||||
from .models import FertilizationEntry, SpreadingSessionItem
|
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
|
||||||
|
|
||||||
|
|
||||||
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
||||||
@@ -56,3 +57,49 @@ def sync_stock_uses_for_spreading_session(session):
|
|||||||
fertilization_plan=None,
|
fertilization_plan=None,
|
||||||
spreading_item=item,
|
spreading_item=item,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def move_fertilization_entries_for_variety_change(change):
|
||||||
|
moved_count = 0
|
||||||
|
old_variety_id = change.old_variety_id
|
||||||
|
new_variety = change.new_variety
|
||||||
|
if old_variety_id is None or new_variety is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
old_plans = (
|
||||||
|
FertilizationPlan.objects
|
||||||
|
.filter(
|
||||||
|
year=change.year,
|
||||||
|
variety_id=old_variety_id,
|
||||||
|
entries__field_id=change.field_id,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related('entries')
|
||||||
|
)
|
||||||
|
|
||||||
|
for old_plan in old_plans:
|
||||||
|
entries_to_move = list(
|
||||||
|
old_plan.entries.filter(
|
||||||
|
field_id=change.field_id,
|
||||||
|
).order_by('id')
|
||||||
|
)
|
||||||
|
if not entries_to_move:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_plan = FertilizationPlan.objects.create(
|
||||||
|
name=f'{change.year}年度 {new_variety.name} 施肥計画(品種変更移動)',
|
||||||
|
year=change.year,
|
||||||
|
variety=new_variety,
|
||||||
|
calc_settings=old_plan.calc_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
FertilizationEntry.objects.filter(
|
||||||
|
id__in=[entry.id for entry in entries_to_move]
|
||||||
|
).update(plan=new_plan)
|
||||||
|
|
||||||
|
create_reserves_for_plan(old_plan)
|
||||||
|
create_reserves_for_plan(new_plan)
|
||||||
|
moved_count += len(entries_to_move)
|
||||||
|
|
||||||
|
return moved_count
|
||||||
|
|||||||
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='変更理由')),
|
||||||
|
('fertilizer_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='変更理由')
|
||||||
|
fertilizer_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):
|
||||||
@@ -34,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||||
field_name = serializers.ReadOnlyField(source='field.name')
|
field_name = serializers.ReadOnlyField(source='field.name')
|
||||||
|
variety_change_count = serializers.SerializerMethodField()
|
||||||
|
latest_variety_change = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
@@ -44,10 +47,38 @@ 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_variety_change_count(self, obj):
|
||||||
|
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||||
|
changes = prefetched.get('variety_changes')
|
||||||
|
if changes is not None:
|
||||||
|
return len(changes)
|
||||||
|
return obj.variety_changes.count()
|
||||||
|
|
||||||
|
def get_latest_variety_change(self, obj):
|
||||||
|
prefetched = getattr(obj, '_prefetched_objects_cache', {})
|
||||||
|
changes = prefetched.get('variety_changes')
|
||||||
|
if changes is not None:
|
||||||
|
latest = changes[0] if changes else None
|
||||||
|
else:
|
||||||
|
latest = obj.variety_changes.select_related('old_variety', 'new_variety').first()
|
||||||
|
if latest is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': latest.id,
|
||||||
|
'changed_at': latest.changed_at,
|
||||||
|
'old_variety_id': latest.old_variety_id,
|
||||||
|
'old_variety_name': latest.old_variety.name if latest.old_variety else None,
|
||||||
|
'new_variety_id': latest.new_variety_id,
|
||||||
|
'new_variety_name': latest.new_variety.name if latest.new_variety else None,
|
||||||
|
'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
74
backend/apps/plans/services.py
Normal file
74
backend/apps/plans/services.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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):
|
||||||
|
from apps.fertilizer.services import move_fertilization_entries_for_variety_change
|
||||||
|
from .services_rice_transplant import move_rice_transplant_entries_for_variety_change
|
||||||
|
|
||||||
|
moved_count = move_fertilization_entries_for_variety_change(change)
|
||||||
|
move_rice_transplant_entries_for_variety_change(change)
|
||||||
|
if moved_count != change.fertilizer_moved_entry_count:
|
||||||
|
change.fertilizer_moved_entry_count = moved_count
|
||||||
|
change.save(update_fields=['fertilizer_moved_entry_count'])
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def _get_variety_id(variety):
|
||||||
|
return getattr(variety, 'id', None)
|
||||||
46
backend/apps/plans/services_rice_transplant.py
Normal file
46
backend/apps/plans/services_rice_transplant.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def move_rice_transplant_entries_for_variety_change(change):
|
||||||
|
old_variety_id = change.old_variety_id
|
||||||
|
new_variety = change.new_variety
|
||||||
|
if old_variety_id is None or new_variety is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
old_plans = (
|
||||||
|
RiceTransplantPlan.objects
|
||||||
|
.filter(
|
||||||
|
year=change.year,
|
||||||
|
variety_id=old_variety_id,
|
||||||
|
entries__field_id=change.field_id,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related('entries')
|
||||||
|
)
|
||||||
|
|
||||||
|
moved_count = 0
|
||||||
|
for old_plan in old_plans:
|
||||||
|
entries_to_move = list(
|
||||||
|
old_plan.entries.filter(field_id=change.field_id).order_by('id')
|
||||||
|
)
|
||||||
|
if not entries_to_move:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_plan = RiceTransplantPlan.objects.create(
|
||||||
|
name=f'{change.year}年度 {new_variety.name} 田植え計画(品種変更移動)',
|
||||||
|
year=change.year,
|
||||||
|
variety=new_variety,
|
||||||
|
default_seed_grams_per_box=old_plan.default_seed_grams_per_box,
|
||||||
|
seedling_boxes_per_tan=old_plan.seedling_boxes_per_tan,
|
||||||
|
notes=old_plan.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
RiceTransplantEntry.objects.filter(
|
||||||
|
id__in=[entry.id for entry in entries_to_move]
|
||||||
|
).update(plan=new_plan)
|
||||||
|
moved_count += len(entries_to_move)
|
||||||
|
|
||||||
|
return moved_count
|
||||||
@@ -1,3 +1,263 @@
|
|||||||
|
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
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
# Create your tests here.
|
from apps.fertilizer.models import FertilizationEntry, FertilizationPlan, Fertilizer
|
||||||
|
from apps.fields.models import Field
|
||||||
|
from apps.materials.models import Material, StockTransaction
|
||||||
|
from apps.materials.stock_service import create_reserves_for_plan
|
||||||
|
from .models import (
|
||||||
|
Crop,
|
||||||
|
Plan,
|
||||||
|
PlanVarietyChange,
|
||||||
|
RiceTransplantEntry,
|
||||||
|
RiceTransplantPlan,
|
||||||
|
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='',
|
||||||
|
)
|
||||||
|
self.other_field = Field.objects.create(
|
||||||
|
name='足川南',
|
||||||
|
address='高知県高岡郡',
|
||||||
|
area_tan='0.8000',
|
||||||
|
area_m2=800,
|
||||||
|
owner_name='吉田',
|
||||||
|
group_name='南',
|
||||||
|
display_order=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.fertilizer_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)
|
||||||
|
|
||||||
|
def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self):
|
||||||
|
material_target = Material.objects.create(
|
||||||
|
name='高度化成14号',
|
||||||
|
material_type=Material.MaterialType.FERTILIZER,
|
||||||
|
)
|
||||||
|
material_spread = Material.objects.create(
|
||||||
|
name='分げつ一発',
|
||||||
|
material_type=Material.MaterialType.FERTILIZER,
|
||||||
|
)
|
||||||
|
fertilizer_target = Fertilizer.objects.create(
|
||||||
|
name='高度化成14号',
|
||||||
|
material=material_target,
|
||||||
|
)
|
||||||
|
fertilizer_spread = Fertilizer.objects.create(
|
||||||
|
name='分げつ一発',
|
||||||
|
material=material_spread,
|
||||||
|
)
|
||||||
|
old_fertilization_plan = FertilizationPlan.objects.create(
|
||||||
|
name='2026年度 にこまる 元肥',
|
||||||
|
year=2026,
|
||||||
|
variety=self.old_variety,
|
||||||
|
calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}],
|
||||||
|
)
|
||||||
|
target_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.field,
|
||||||
|
fertilizer=fertilizer_target,
|
||||||
|
bags='4.00',
|
||||||
|
actual_bags=None,
|
||||||
|
)
|
||||||
|
spread_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.field,
|
||||||
|
fertilizer=fertilizer_spread,
|
||||||
|
bags='3.00',
|
||||||
|
actual_bags='1.0000',
|
||||||
|
)
|
||||||
|
untouched_entry = FertilizationEntry.objects.create(
|
||||||
|
plan=old_fertilization_plan,
|
||||||
|
field=self.other_field,
|
||||||
|
fertilizer=fertilizer_target,
|
||||||
|
bags='2.00',
|
||||||
|
actual_bags=None,
|
||||||
|
)
|
||||||
|
create_reserves_for_plan(old_fertilization_plan)
|
||||||
|
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'variety': self.new_variety.id},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
change = PlanVarietyChange.objects.get(plan=self.plan)
|
||||||
|
self.assertEqual(change.fertilizer_moved_entry_count, 2)
|
||||||
|
|
||||||
|
old_fertilization_plan.refresh_from_db()
|
||||||
|
new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get(
|
||||||
|
year=2026,
|
||||||
|
variety=self.new_variety,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
new_plan.name,
|
||||||
|
f'2026年度 {self.new_variety.name} 施肥計画(品種変更移動)',
|
||||||
|
)
|
||||||
|
self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings)
|
||||||
|
|
||||||
|
target_entry.refresh_from_db()
|
||||||
|
spread_entry.refresh_from_db()
|
||||||
|
untouched_entry.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(target_entry.plan_id, new_plan.id)
|
||||||
|
self.assertEqual(spread_entry.plan_id, new_plan.id)
|
||||||
|
self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id)
|
||||||
|
|
||||||
|
old_reserves = list(
|
||||||
|
StockTransaction.objects.filter(
|
||||||
|
fertilization_plan=old_fertilization_plan,
|
||||||
|
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||||
|
).order_by('material__name')
|
||||||
|
)
|
||||||
|
new_reserves = list(
|
||||||
|
StockTransaction.objects.filter(
|
||||||
|
fertilization_plan=new_plan,
|
||||||
|
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||||
|
).order_by('material__name')
|
||||||
|
)
|
||||||
|
self.assertEqual(len(old_reserves), 1)
|
||||||
|
self.assertEqual(len(new_reserves), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{(reserve.material_id, reserve.quantity) for reserve in old_reserves},
|
||||||
|
{
|
||||||
|
(material_target.id, untouched_entry.bags),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{(reserve.material_id, reserve.quantity) for reserve in new_reserves},
|
||||||
|
{
|
||||||
|
(material_target.id, target_entry.bags),
|
||||||
|
(material_spread.id, spread_entry.bags),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_serializer_update_moves_rice_transplant_entries_for_target_field(self):
|
||||||
|
old_rice_plan = RiceTransplantPlan.objects.create(
|
||||||
|
name='2026年度 にこまる 田植え計画',
|
||||||
|
year=2026,
|
||||||
|
variety=self.old_variety,
|
||||||
|
default_seed_grams_per_box='200.00',
|
||||||
|
seedling_boxes_per_tan='12.00',
|
||||||
|
notes='旧計画メモ',
|
||||||
|
)
|
||||||
|
target_entry = RiceTransplantEntry.objects.create(
|
||||||
|
plan=old_rice_plan,
|
||||||
|
field=self.field,
|
||||||
|
installed_seedling_boxes='14.40',
|
||||||
|
seed_grams_per_box='200.00',
|
||||||
|
)
|
||||||
|
other_entry = RiceTransplantEntry.objects.create(
|
||||||
|
plan=old_rice_plan,
|
||||||
|
field=self.other_field,
|
||||||
|
installed_seedling_boxes='9.60',
|
||||||
|
seed_grams_per_box='200.00',
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = PlanSerializer(
|
||||||
|
instance=self.plan,
|
||||||
|
data={'variety': self.new_variety.id},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
target_entry.refresh_from_db()
|
||||||
|
other_entry.refresh_from_db()
|
||||||
|
|
||||||
|
new_rice_plan = RiceTransplantPlan.objects.exclude(id=old_rice_plan.id).get(
|
||||||
|
year=2026,
|
||||||
|
variety=self.new_variety,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
new_rice_plan.name,
|
||||||
|
f'2026年度 {self.new_variety.name} 田植え計画(品種変更移動)',
|
||||||
|
)
|
||||||
|
self.assertEqual(new_rice_plan.default_seed_grams_per_box, Decimal('200.00'))
|
||||||
|
self.assertEqual(new_rice_plan.seedling_boxes_per_tan, Decimal('12.00'))
|
||||||
|
self.assertEqual(new_rice_plan.notes, old_rice_plan.notes)
|
||||||
|
self.assertEqual(target_entry.plan_id, new_rice_plan.id)
|
||||||
|
self.assertEqual(other_entry.plan_id, old_rice_plan.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
|
||||||
|
|
||||||
|
|
||||||
@@ -24,11 +25,15 @@ class VarietyViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class PlanViewSet(viewsets.ModelViewSet):
|
class PlanViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Plan.objects.all()
|
queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related(
|
||||||
|
'variety_changes',
|
||||||
|
'variety_changes__old_variety',
|
||||||
|
'variety_changes__new_variety',
|
||||||
|
)
|
||||||
serializer_class = PlanSerializer
|
serializer_class = PlanSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Plan.objects.all()
|
queryset = self.queryset
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
@@ -120,14 +125,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})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Field, Crop, Plan } from '@/types';
|
import { Field, Crop, Plan } from '@/types';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
|
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react';
|
||||||
|
|
||||||
interface SummaryItem {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -48,6 +48,13 @@ export default function AllocationPage() {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
||||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const timer = window.setTimeout(() => setToast(null), 4000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('allocationYear', String(year));
|
localStorage.setItem('allocationYear', String(year));
|
||||||
@@ -233,17 +240,46 @@ export default function AllocationPage() {
|
|||||||
const existingPlan = getPlanForField(fieldId);
|
const existingPlan = getPlanForField(fieldId);
|
||||||
|
|
||||||
if (!existingPlan || !existingPlan.crop) return;
|
if (!existingPlan || !existingPlan.crop) return;
|
||||||
|
if ((existingPlan.variety || null) === variety) return;
|
||||||
|
|
||||||
|
const nextVarietyName =
|
||||||
|
variety === null
|
||||||
|
? '(品種未選択)'
|
||||||
|
: getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明';
|
||||||
|
const currentVarietyName = existingPlan.variety_name || '(品種未選択)';
|
||||||
|
|
||||||
|
const shouldProceed = confirm(
|
||||||
|
[
|
||||||
|
`品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`,
|
||||||
|
'施肥計画・田植え計画の関連エントリが自動で移動する場合があります。',
|
||||||
|
'実行しますか?',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
if (!shouldProceed) return;
|
||||||
|
|
||||||
setSaving(fieldId);
|
setSaving(fieldId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/plans/${existingPlan.id}/`, {
|
const res = await api.patch(`/plans/${existingPlan.id}/`, {
|
||||||
variety,
|
variety,
|
||||||
notes: existingPlan.notes,
|
notes: existingPlan.notes,
|
||||||
});
|
});
|
||||||
|
const updatedPlan: Plan = res.data;
|
||||||
|
const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0;
|
||||||
|
setToast({
|
||||||
|
type: 'success',
|
||||||
|
message:
|
||||||
|
movedCount > 0
|
||||||
|
? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。`
|
||||||
|
: '品種を変更しました。関連する施肥計画の移動はありませんでした。',
|
||||||
|
});
|
||||||
await fetchData(true);
|
await fetchData(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save variety:', error);
|
console.error('Failed to save variety:', error);
|
||||||
|
setToast({
|
||||||
|
type: 'error',
|
||||||
|
message: '品種変更に失敗しました。',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
@@ -563,6 +599,17 @@ export default function AllocationPage() {
|
|||||||
{/* メインコンテンツ */}
|
{/* メインコンテンツ */}
|
||||||
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'border-green-300 bg-green-50 text-green-800'
|
||||||
|
: 'border-red-300 bg-red-50 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
作付け計画 <span className="text-green-700">{year}年度</span>
|
作付け計画 <span className="text-green-700">{year}年度</span>
|
||||||
@@ -887,6 +934,7 @@ export default function AllocationPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectedVarietyId || ''}
|
value={selectedVarietyId || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -908,6 +956,21 @@ export default function AllocationPage() {
|
|||||||
))}
|
))}
|
||||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||||
</select>
|
</select>
|
||||||
|
{plan?.latest_variety_change && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
|
||||||
|
title={[
|
||||||
|
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
|
||||||
|
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
|
||||||
|
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
|
||||||
|
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
|
||||||
|
].join('\n')}
|
||||||
|
>
|
||||||
|
<History className="h-3 w-3" />
|
||||||
|
変更履歴あり
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface Plan {
|
|||||||
variety: number;
|
variety: number;
|
||||||
variety_name: string;
|
variety_name: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
variety_change_count?: number;
|
||||||
|
latest_variety_change?: {
|
||||||
|
id: number;
|
||||||
|
changed_at: string;
|
||||||
|
old_variety_id: number | null;
|
||||||
|
old_variety_name: string | null;
|
||||||
|
new_variety_id: number | null;
|
||||||
|
new_variety_name: string | null;
|
||||||
|
fertilizer_moved_entry_count: number;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Fertilizer {
|
export interface Fertilizer {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ
|
|||||||
|
|
||||||
## 8. 追加提案に対する評価
|
## 8. 追加提案に対する評価
|
||||||
|
|
||||||
ユーザーからの追加提案:
|
ユーザーからの追加提案(初期):
|
||||||
|
|
||||||
- 変更履歴は必要
|
- 変更履歴は必要
|
||||||
- 散布済み Entry も新品種計画へ移動する案を第一候補
|
- 散布済み Entry も新品種計画へ移動する案を第一候補
|
||||||
@@ -236,6 +236,9 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ
|
|||||||
- 圃場グループは対応不要
|
- 圃場グループは対応不要
|
||||||
- 田植え計画も同様に移動
|
- 田植え計画も同様に移動
|
||||||
|
|
||||||
|
> **補足(最終確定)**: 施肥 Entry の扱いは後述 8-3 の検討を経て **(B) 対象圃場の全件を新品種計画へ移動** に確定した。
|
||||||
|
> 履歴スナップショットは将来必要になった時点で追加検討とする。
|
||||||
|
|
||||||
この提案には良い点が多い一方で、現行実装のまま採ると危険な点もある。
|
この提案には良い点が多い一方で、現行実装のまま採ると危険な点もある。
|
||||||
|
|
||||||
### 8-1. 採用しやすい点
|
### 8-1. 採用しやすい点
|
||||||
@@ -251,10 +254,11 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ
|
|||||||
- 変更理由 `reason` があると運用上かなり有用
|
- 変更理由 `reason` があると運用上かなり有用
|
||||||
- 自動移動結果の件数も履歴に残せると監査しやすい
|
- 自動移動結果の件数も履歴に残せると監査しやすい
|
||||||
|
|
||||||
#### b. 未散布 Entry の移動
|
#### b. 対象圃場の施肥 Entry を新品種計画へ集約する
|
||||||
|
|
||||||
これは方向性としてかなり自然。
|
現在の計画・栽培記録・将来の集計を一貫させるには、
|
||||||
「まだ実施していない将来計画」は新品種側へ寄せ、引当も付け替えるのは業務的に納得感が高い。
|
対象圃場の施肥 Entry を既散布/未散布で分断せず、新品種計画へ集約する方が自然。
|
||||||
|
RESERVE もその plan 構成に合わせて再生成する。
|
||||||
|
|
||||||
#### c. 田植え計画も同様に扱う
|
#### c. 田植え計画も同様に扱う
|
||||||
|
|
||||||
@@ -263,7 +267,7 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ
|
|||||||
|
|
||||||
### 8-2. そのまま採ると危険な点
|
### 8-2. そのまま採ると危険な点
|
||||||
|
|
||||||
#### a. 散布済み Entry を新品種計画へ移動する案
|
#### a. 全 Entry を新品種計画へ移動する案
|
||||||
|
|
||||||
これはもっとも議論が必要。
|
これはもっとも議論が必要。
|
||||||
|
|
||||||
@@ -282,13 +286,12 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ
|
|||||||
- 施肥計画 PDF や一覧で、後から見る人が経緯を誤解しやすい
|
- 施肥計画 PDF や一覧で、後から見る人が経緯を誤解しやすい
|
||||||
- 「なぜこの新品種計画に既散布分が入っているのか」を履歴表示なしでは理解できない
|
- 「なぜこの新品種計画に既散布分が入っているのか」を履歴表示なしでは理解できない
|
||||||
|
|
||||||
したがって、散布済み Entry を移動するなら、少なくとも
|
したがって、全 Entry を移動するなら、少なくとも
|
||||||
`変更前品種で発生した実績である`
|
`変更前品種で発生した実績である`
|
||||||
ことが UI で明示される必要がある。
|
ことが UI で明示される必要がある。
|
||||||
|
|
||||||
現時点では、実装の安全性だけで見ると
|
ただし、将来の栽培記録実装では「その圃場・その年度の最終品種」に
|
||||||
`散布済み Entry は旧計画に残す`
|
施肥情報を集約したい要求が強いため、最終的には B案を採用する。
|
||||||
方が素直。
|
|
||||||
|
|
||||||
#### b. Entry の plan FK 付け替えだけでは履歴の意味が弱い
|
#### b. Entry の plan FK 付け替えだけでは履歴の意味が弱い
|
||||||
|
|
||||||
@@ -320,13 +323,11 @@ issue 本文にもあるように、同年度・同品種で複数計画があ
|
|||||||
|
|
||||||
自動選択(最新)は暫定対応としてはあり得るが、本命仕様にはしにくい。
|
自動選択(最新)は暫定対応としてはあり得るが、本命仕様にはしにくい。
|
||||||
|
|
||||||
### 8-3. 私の現時点の推奨
|
### 8-3. 推奨と最終決定
|
||||||
|
|
||||||
#### 変更履歴
|
#### 変更履歴 ✅ 採用確定
|
||||||
|
|
||||||
採用推奨。
|
モデル設計:
|
||||||
|
|
||||||
ただしモデルは次の方が使いやすい。
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
PlanVarietyChange
|
PlanVarietyChange
|
||||||
@@ -337,35 +338,37 @@ PlanVarietyChange
|
|||||||
old_variety FK(Variety, SET_NULL, null=True)
|
old_variety FK(Variety, SET_NULL, null=True)
|
||||||
new_variety FK(Variety, SET_NULL, null=True)
|
new_variety FK(Variety, SET_NULL, null=True)
|
||||||
reason text blank
|
reason text blank
|
||||||
|
moved_entry_count int default=0 # 自動移動した施肥エントリ数(監査用)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 散布済み Entry の扱い
|
- `plan FK` だけでなく `field_id` と `year` を冗長保持した方が将来参照しやすい
|
||||||
|
- `reason` があると運用上かなり有用
|
||||||
|
- `moved_entry_count` で自動移動の件数を残すことで監査ログを兼ねる
|
||||||
|
|
||||||
現時点の推奨は `(A) 旧計画に残す`。
|
#### 施肥 Entry の扱い ✅ **(B) 対象圃場の全件を新品種計画へ移動** 確定
|
||||||
|
|
||||||
理由:
|
理由:
|
||||||
|
|
||||||
- 履歴解釈が明確
|
- 栽培記録の観点では「その圃場・その年度に最終的に作った品種」に施肥情報を集約した方が自然
|
||||||
- PDF/一覧での意味が崩れにくい
|
- 既散布/未散布で計画が分裂すると、将来の集計や参照で特別処理が増える
|
||||||
- 将来「既散布分も移したい」へ広げる余地を残せる
|
- 旧計画側に圃場が残らないため、画面上の違和感が少ない
|
||||||
|
- `actual_bags` を含めて plan ごと付け替えることで、圃場単位の施肥履歴を新品種側へ一貫して寄せられる
|
||||||
|
|
||||||
もし `(B) 新品種計画へ移動` を採るなら、
|
散布済み/未散布に関係なく、**対象圃場の FertilizationEntry は全件移動**する。
|
||||||
変更履歴表示と監査ログ表示を先に入れた方が安全。
|
RESERVE も移動後の plan 構成に合わせて新旧 plan 単位で再生成する。
|
||||||
|
|
||||||
#### 未散布 Entry の扱い
|
#### 圃場グループ ✅ 対応不要 確定
|
||||||
|
|
||||||
採用推奨。
|
圃場マスタの `group_name` は現在値として扱う。
|
||||||
RESERVE 付け替えもこの方針と整合する。
|
帳票側でスナップショットが必要になれば別途検討。
|
||||||
|
|
||||||
#### 圃場グループ
|
#### 田植え計画 ✅ 対応確定(施肥とは判定軸が異なる)
|
||||||
|
|
||||||
現時点では対応不要でよさそう。
|
施肥だけ直して田植えを放置すると整合しないため同時に対応する。
|
||||||
少なくとも今回のコア問題ではない。
|
ただし田植え計画には actual_bags 相当の実績概念がないため、
|
||||||
|
**対象圃場の Entry は全件移動**(未散布判定なし)。
|
||||||
#### 田植え計画
|
将来、田植え実績との連携が実装された場合は改めて設計する。
|
||||||
|
実装順は施肥の後でよい。
|
||||||
採用推奨。
|
|
||||||
ただし施肥より軽いので、実装順は施肥の後でよい。
|
|
||||||
|
|
||||||
### 8-4. 移動先計画の選び方への見解
|
### 8-4. 移動先計画の選び方への見解
|
||||||
|
|
||||||
@@ -391,19 +394,83 @@ RESERVE 付け替えもこの方針と整合する。
|
|||||||
|
|
||||||
のような命名。
|
のような命名。
|
||||||
|
|
||||||
### 8-5. 実装ステップ案への見解
|
### 8-5. 実装ステップ(確定版)
|
||||||
|
|
||||||
提案の段階実装は良い。
|
散布済みEntryの扱いが確定したため、以下の順で実装する。
|
||||||
ただし Step 2 の前に「散布済みを残すか移すか」を固定した方がよい。
|
|
||||||
|
|
||||||
推奨順は次の通り。
|
1. `PlanVarietyChange` モデル追加(履歴記録のみ・既存データに触らない)
|
||||||
|
|
||||||
1. `PlanVarietyChange` 追加
|
|
||||||
2. 品種変更トリガーのサービス追加
|
2. 品種変更トリガーのサービス追加
|
||||||
3. まずは `未散布 Entry のみ移動` を施肥計画で実装
|
3. 対象圃場の施肥 Entry `全件` を新品種計画(常に新規作成)へ移動する処理を実装
|
||||||
4. RESERVE 付け替えと `actual_bags` 再集計を確認
|
4. RESERVE 付け替えと `actual_bags` 再集計を確認
|
||||||
5. 田植え計画へ横展開
|
5. 田植え計画へ横展開
|
||||||
6. allocation 画面の履歴インジケータ追加
|
6. allocation 画面の履歴インジケータ追加
|
||||||
7. 必要なら散布済み Entry 移動案を再検討
|
|
||||||
|
|
||||||
この順だと、もっとも危険な「履歴の意味が壊れる変更」を後ろ倒しにできる。
|
---
|
||||||
|
|
||||||
|
## 9. 確定仕様まとめ
|
||||||
|
|
||||||
|
> 更新日: 2026-04-05
|
||||||
|
|
||||||
|
### 9-1. 決定事項一覧
|
||||||
|
|
||||||
|
| 項目 | 決定内容 |
|
||||||
|
|---|---|
|
||||||
|
| 施肥 Entry | **対象圃場の全件を新品種計画へ移動 + RESERVE再生成** |
|
||||||
|
| 移動先計画の選び方 | **常に新規作成**(既存計画には集約しない) |
|
||||||
|
| 移動先計画の命名 | `{year}年度 {品種名} 施肥計画(品種変更移動)` |
|
||||||
|
| 変更履歴 | **PlanVarietyChange モデルを新設** |
|
||||||
|
| 圃場グループ | **対応不要**(現在値扱いのまま) |
|
||||||
|
| 田植え計画 | **現時点では全件移動**(実績概念なし)。将来の実績連携実装後に再設計(実装は施肥の後) |
|
||||||
|
|
||||||
|
### 9-2. 品種変更時の自動処理フロー
|
||||||
|
|
||||||
|
`Plan.variety` が `A → B` に変更されたとき:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PlanVarietyChange を記録
|
||||||
|
field, year, plan, changed_at, old_variety=A, new_variety=B, reason
|
||||||
|
|
||||||
|
2. 施肥計画エントリの移動
|
||||||
|
|
||||||
|
対象: FertilizationPlan.variety=A かつ year=変更年度 かつ
|
||||||
|
FertilizationEntry.field=変更圃場(全件)
|
||||||
|
処理:
|
||||||
|
a. variety=B, year=変更年度 の新 FertilizationPlan を作成
|
||||||
|
名前: "{year}年度 {B品種名} 施肥計画(品種変更移動)"
|
||||||
|
b. 対象 FertilizationEntry の plan FK を新 plan へ付け替え
|
||||||
|
c. 旧 plan 全体の RESERVE を再生成(stock_service.create_reserves_for_plan(旧plan))
|
||||||
|
※ RESERVE は plan 単位で全置換管理のため、エントリ単位ではなく plan 単位で呼び出す
|
||||||
|
d. 新 plan 全体の RESERVE を生成(stock_service.create_reserves_for_plan(新plan))
|
||||||
|
e. PlanVarietyChange.moved_entry_count に移動件数を記録
|
||||||
|
|
||||||
|
3. 田植え計画エントリの移動
|
||||||
|
|
||||||
|
田植え計画には施肥計画の actual_bags に相当する実績概念がまだない
|
||||||
|
(RiceTransplantEntry は installed_seedling_boxes のみ、散布済み/未散布の区別がない)。
|
||||||
|
そのため、現時点では 対象圃場の Entry を全件移動 とする。
|
||||||
|
|
||||||
|
将来、田植え実績(田植え日・実績箱数等)との連携が実装された場合は、
|
||||||
|
「実施済み Entry は旧計画に残す」方針に揃えて再設計すること。
|
||||||
|
|
||||||
|
対象: RiceTransplantPlan.variety=A かつ year=変更年度 かつ
|
||||||
|
RiceTransplantEntry.field=変更圃場(全件)
|
||||||
|
処理:
|
||||||
|
a. variety=B, year=変更年度 の新 RiceTransplantPlan を作成
|
||||||
|
名前: "{year}年度 {B品種名} 田植え計画(品種変更移動)"
|
||||||
|
b. 対象 RiceTransplantEntry の plan FK を新 plan へ付け替え
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9-3. 変更しないもの(影響なし)
|
||||||
|
|
||||||
|
- `SpreadingSessionItem` — field+fertilizer リンクのため変更不要
|
||||||
|
- `actual_bags` 集計ロジック — 現方針では再利用可能。
|
||||||
|
ただし **同一 year+field+fertilizer の FertilizationEntry が複数計画にまたがって共存しないこと** が前提。
|
||||||
|
この制約は仕様上の invariant として守る必要がある(移動処理でエントリを複製しないこと)。
|
||||||
|
- `candidate_fields` API — Plan.variety 変更後は自然に新品種で候補が返る
|
||||||
|
- `WorkRecord` — 運搬/散布実績への 1:1 参照のため影響なし
|
||||||
|
|
||||||
|
### 9-4. 未解決・将来検討
|
||||||
|
|
||||||
|
- 変更履歴のスナップショットをどこまで持つか → 実装後に見直し
|
||||||
|
- allocation 画面の変更履歴インジケータ(実装ステップ6)
|
||||||
|
- `actual_bags` 集計を `year+field+fertilizer` から `plan単位` へ変更する大規模リファクタ(中長期)
|
||||||
|
|||||||
Reference in New Issue
Block a user