施肥散布実績機能を実装し運搬・作業記録・在庫連携を追加
This commit is contained in:
@@ -2,6 +2,7 @@ from django.contrib import admin
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +18,7 @@ class FertilizationEntryInline(admin.TabularInline):
|
||||
|
||||
@admin.register(FertilizationPlan)
|
||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'variety']
|
||||
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||
list_filter = ['year']
|
||||
inlines = [FertilizationEntryInline]
|
||||
|
||||
@@ -60,3 +61,15 @@ class DeliveryGroupAdmin(admin.ModelAdmin):
|
||||
class DeliveryTripAdmin(admin.ModelAdmin):
|
||||
list_display = ['delivery_plan', 'order', 'name', 'date']
|
||||
inlines = [DeliveryTripItemInline]
|
||||
|
||||
|
||||
class SpreadingSessionItemInline(admin.TabularInline):
|
||||
model = SpreadingSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SpreadingSession)
|
||||
class SpreadingSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['year', 'date', 'name']
|
||||
list_filter = ['year', 'date']
|
||||
inlines = [SpreadingSessionItemInline]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0007_delivery_models'),
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='散布日')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績',
|
||||
'verbose_name_plural': '散布実績',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fertilizationentry',
|
||||
name='actual_bags',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='実績袋数'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SpreadingSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actual_bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='実散布袋数')),
|
||||
('planned_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='計画袋数スナップショット')),
|
||||
('delivered_bags_snapshot', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='運搬済み袋数スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '散布実績明細',
|
||||
'verbose_name_plural': '散布実績明細',
|
||||
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
|
||||
'unique_together': {('session', 'field', 'fertilizer')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,13 @@ class FertilizationEntry(models.Model):
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||
actual_bags = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='実績袋数',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '施肥エントリ'
|
||||
@@ -179,3 +186,63 @@ class DeliveryTripItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}袋"
|
||||
|
||||
|
||||
class SpreadingSession(models.Model):
|
||||
"""散布日単位の実績"""
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='散布日')
|
||||
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
|
||||
notes = models.TextField(blank=True, default='', 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 = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
label = self.name.strip() or f'{self.date}'
|
||||
return f'{self.year} {label}'
|
||||
|
||||
|
||||
class SpreadingSessionItem(models.Model):
|
||||
"""散布実績明細:圃場×肥料ごとの実績"""
|
||||
session = models.ForeignKey(
|
||||
SpreadingSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='散布実績',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
|
||||
)
|
||||
fertilizer = models.ForeignKey(
|
||||
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||
)
|
||||
actual_bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='実散布袋数')
|
||||
planned_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='計画袋数スナップショット',
|
||||
)
|
||||
delivered_bags_snapshot = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
verbose_name='運搬済み袋数スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '散布実績明細'
|
||||
verbose_name_plural = '散布実績明細'
|
||||
unique_together = [['session', 'field', 'fertilizer']]
|
||||
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{self.session} / {self.field.name} / '
|
||||
f'{self.fertilizer.name}: {self.actual_bags}袋'
|
||||
)
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.workrecords.services import sync_delivery_work_record
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
DeliveryGroup,
|
||||
DeliveryGroupField,
|
||||
DeliveryPlan,
|
||||
DeliveryTrip,
|
||||
DeliveryTripItem,
|
||||
FertilizationEntry,
|
||||
FertilizationPlan,
|
||||
Fertilizer,
|
||||
SpreadingSession,
|
||||
SpreadingSessionItem,
|
||||
)
|
||||
from .services import sync_actual_bags_for_pairs, sync_spreading_session_side_effects
|
||||
|
||||
|
||||
class FertilizerSerializer(serializers.ModelSerializer):
|
||||
@@ -36,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = FertilizationEntry
|
||||
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'actual_bags',
|
||||
]
|
||||
|
||||
|
||||
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
@@ -45,15 +68,34 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||
field_count = serializers.SerializerMethodField()
|
||||
fertilizer_count = serializers.SerializerMethodField()
|
||||
planned_total_bags = serializers.SerializerMethodField()
|
||||
spread_total_bags = serializers.SerializerMethodField()
|
||||
remaining_total_bags = serializers.SerializerMethodField()
|
||||
spread_status = serializers.SerializerMethodField()
|
||||
is_confirmed = serializers.BooleanField(read_only=True)
|
||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FertilizationPlan
|
||||
fields = [
|
||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
|
||||
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
|
||||
'id',
|
||||
'name',
|
||||
'year',
|
||||
'variety',
|
||||
'variety_name',
|
||||
'crop_name',
|
||||
'calc_settings',
|
||||
'entries',
|
||||
'field_count',
|
||||
'fertilizer_count',
|
||||
'planned_total_bags',
|
||||
'spread_total_bags',
|
||||
'remaining_total_bags',
|
||||
'spread_status',
|
||||
'is_confirmed',
|
||||
'confirmed_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_variety_name(self, obj):
|
||||
@@ -68,9 +110,32 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||
def get_fertilizer_count(self, obj):
|
||||
return obj.entries.values('fertilizer').distinct().count()
|
||||
|
||||
def get_planned_total_bags(self, obj):
|
||||
total = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_spread_total_bags(self, obj):
|
||||
total = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(total)
|
||||
|
||||
def get_remaining_total_bags(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
return str(planned - actual)
|
||||
|
||||
def get_spread_status(self, obj):
|
||||
planned = sum((entry.bags or Decimal('0')) for entry in obj.entries.all())
|
||||
actual = sum((entry.actual_bags or Decimal('0')) for entry in obj.entries.all())
|
||||
if actual <= 0:
|
||||
return 'unspread'
|
||||
if actual > planned:
|
||||
return 'over_applied'
|
||||
if actual < planned:
|
||||
return 'partial'
|
||||
return 'completed'
|
||||
|
||||
|
||||
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
"""保存用(entries を一括で受け取る)"""
|
||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -80,7 +145,8 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
entries_data = validated_data.pop('entries', [])
|
||||
plan = FertilizationPlan.objects.create(**validated_data)
|
||||
self._save_entries(plan, entries_data)
|
||||
pairs = self._save_entries(plan, entries_data)
|
||||
sync_actual_bags_for_pairs(plan.year, pairs)
|
||||
return plan
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -90,21 +156,23 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||
instance.save()
|
||||
if entries_data is not None:
|
||||
instance.entries.all().delete()
|
||||
self._save_entries(instance, entries_data)
|
||||
pairs = self._save_entries(instance, entries_data)
|
||||
sync_actual_bags_for_pairs(instance.year, pairs)
|
||||
return instance
|
||||
|
||||
def _save_entries(self, plan, entries_data):
|
||||
pairs = set()
|
||||
for entry in entries_data:
|
||||
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||
FertilizationEntry.objects.create(
|
||||
plan=plan,
|
||||
field_id=entry['field_id'],
|
||||
fertilizer_id=entry['fertilizer_id'],
|
||||
bags=entry['bags'],
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
# ─── 運搬計画 ────────────────────────────────────────────────────────────
|
||||
|
||||
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||
name = serializers.CharField(source='field.name', read_only=True)
|
||||
@@ -128,18 +196,51 @@ class DeliveryGroupReadSerializer(serializers.ModelSerializer):
|
||||
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
spread_bags = serializers.SerializerMethodField()
|
||||
remaining_bags = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DeliveryTripItem
|
||||
fields = ['id', 'field', 'field_name', 'fertilizer', 'fertilizer_name', 'bags']
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'bags',
|
||||
'spread_bags',
|
||||
'remaining_bags',
|
||||
]
|
||||
|
||||
def get_spread_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
return str(total or Decimal('0'))
|
||||
|
||||
def get_remaining_bags(self, obj):
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=obj.trip.delivery_plan.year,
|
||||
field_id=obj.field_id,
|
||||
fertilizer_id=obj.fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
spread_total = total or Decimal('0')
|
||||
return str(obj.bags - spread_total)
|
||||
|
||||
|
||||
class DeliveryTripReadSerializer(serializers.ModelSerializer):
|
||||
items = DeliveryTripItemSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryTrip
|
||||
fields = ['id', 'order', 'name', 'date', 'items']
|
||||
fields = ['id', 'order', 'name', 'date', 'work_record_id', 'items']
|
||||
|
||||
|
||||
class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||
@@ -149,8 +250,13 @@ class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id', 'year', 'name', 'group_count', 'trip_count',
|
||||
'created_at', 'updated_at',
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'group_count',
|
||||
'trip_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_group_count(self, obj):
|
||||
@@ -170,20 +276,27 @@ class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DeliveryPlan
|
||||
fields = [
|
||||
'id', 'year', 'name', 'groups', 'trips',
|
||||
'unassigned_fields', 'available_fertilizers', 'all_entries',
|
||||
'created_at', 'updated_at',
|
||||
'id',
|
||||
'year',
|
||||
'name',
|
||||
'groups',
|
||||
'trips',
|
||||
'unassigned_fields',
|
||||
'available_fertilizers',
|
||||
'all_entries',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_unassigned_fields(self, obj):
|
||||
assigned_ids = DeliveryGroupField.objects.filter(
|
||||
delivery_plan=obj
|
||||
).values_list('field_id', flat=True)
|
||||
# 年度の施肥計画に含まれる全圃場
|
||||
plan_field_ids = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).values_list('field_id', flat=True).distinct()
|
||||
from apps.fields.models import Field
|
||||
|
||||
unassigned = Field.objects.filter(
|
||||
id__in=plan_field_ids
|
||||
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
||||
@@ -197,20 +310,20 @@ class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
||||
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||
|
||||
def get_all_entries(self, obj):
|
||||
"""年度の全施肥計画のエントリ(フロントで袋数計算に使用)"""
|
||||
entries = FertilizationEntry.objects.filter(
|
||||
plan__year=obj.year
|
||||
).select_related('field', 'fertilizer')
|
||||
return [
|
||||
{
|
||||
'field': e.field_id,
|
||||
'field_name': e.field.name,
|
||||
'field_area_tan': str(e.field.area_tan),
|
||||
'fertilizer': e.fertilizer_id,
|
||||
'fertilizer_name': e.fertilizer.name,
|
||||
'bags': str(e.bags),
|
||||
'field': entry.field_id,
|
||||
'field_name': entry.field.name,
|
||||
'field_area_tan': str(entry.field.area_tan),
|
||||
'fertilizer': entry.fertilizer_id,
|
||||
'fertilizer_name': entry.fertilizer.name,
|
||||
'bags': str(entry.bags),
|
||||
'actual_bags': str(entry.actual_bags) if entry.actual_bags is not None else None,
|
||||
}
|
||||
for e in entries
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
|
||||
@@ -245,13 +358,13 @@ class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
||||
return instance
|
||||
|
||||
def _save_groups(self, plan, groups_data):
|
||||
for g_data in groups_data:
|
||||
for group_data in groups_data:
|
||||
group = DeliveryGroup.objects.create(
|
||||
delivery_plan=plan,
|
||||
name=g_data['name'],
|
||||
order=g_data.get('order', 0),
|
||||
name=group_data['name'],
|
||||
order=group_data.get('order', 0),
|
||||
)
|
||||
for field_id in g_data.get('field_ids', []):
|
||||
for field_id in group_data.get('field_ids', []):
|
||||
DeliveryGroupField.objects.create(
|
||||
delivery_plan=plan,
|
||||
group=group,
|
||||
@@ -259,17 +372,116 @@ class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
def _save_trips(self, plan, trips_data):
|
||||
for t_data in trips_data:
|
||||
for trip_data in trips_data:
|
||||
trip = DeliveryTrip.objects.create(
|
||||
delivery_plan=plan,
|
||||
order=t_data.get('order', 0),
|
||||
name=t_data.get('name', ''),
|
||||
date=t_data.get('date'),
|
||||
order=trip_data.get('order', 0),
|
||||
name=trip_data.get('name', ''),
|
||||
date=trip_data.get('date'),
|
||||
)
|
||||
for item in t_data.get('items', []):
|
||||
for item in trip_data.get('items', []):
|
||||
DeliveryTripItem.objects.create(
|
||||
trip=trip,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
bags=item['bags'],
|
||||
)
|
||||
sync_delivery_work_record(trip)
|
||||
|
||||
|
||||
class SpreadingSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'fertilizer',
|
||||
'fertilizer_name',
|
||||
'actual_bags',
|
||||
'planned_bags_snapshot',
|
||||
'delivered_bags_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'name',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
|
||||
class SpreadingSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field_id = serializers.IntegerField()
|
||||
fertilizer_id = serializers.IntegerField()
|
||||
actual_bags = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
planned_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
delivered_bags_snapshot = serializers.DecimalField(max_digits=10, decimal_places=4)
|
||||
|
||||
|
||||
class SpreadingSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = SpreadingSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SpreadingSession
|
||||
fields = ['id', 'year', 'date', 'name', 'notes', 'items']
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
if item['actual_bags'] <= 0:
|
||||
raise serializers.ValidationError('actual_bags は 0 より大きい値を指定してください。')
|
||||
key = (item['field_id'], item['fertilizer_id'])
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で field + fertilizer を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
session = SpreadingSession.objects.create(**validated_data)
|
||||
new_pairs = self._replace_items(session, items_data)
|
||||
sync_spreading_session_side_effects(session, new_pairs)
|
||||
return session
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
old_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
new_pairs = self._replace_items(instance, items_data)
|
||||
sync_spreading_session_side_effects(instance, old_pairs | new_pairs)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
new_pairs = set()
|
||||
for item in items_data:
|
||||
new_pairs.add((item['field_id'], item['fertilizer_id']))
|
||||
SpreadingSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field_id'],
|
||||
fertilizer_id=item['fertilizer_id'],
|
||||
actual_bags=item['actual_bags'],
|
||||
planned_bags_snapshot=item['planned_bags_snapshot'],
|
||||
delivered_bags_snapshot=item['delivered_bags_snapshot'],
|
||||
)
|
||||
return new_pairs
|
||||
|
||||
65
backend/apps/fertilizer/services.py
Normal file
65
backend/apps/fertilizer/services.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
|
||||
from apps.materials.models import StockTransaction
|
||||
from apps.workrecords.services import sync_spreading_work_record
|
||||
from .models import FertilizationEntry, SpreadingSessionItem
|
||||
|
||||
|
||||
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
|
||||
pairs = {
|
||||
(int(field_id), int(fertilizer_id))
|
||||
for field_id, fertilizer_id in field_fertilizer_pairs
|
||||
}
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
for field_id, fertilizer_id in pairs:
|
||||
total = (
|
||||
SpreadingSessionItem.objects.filter(
|
||||
session__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).aggregate(total=Sum('actual_bags'))['total']
|
||||
)
|
||||
FertilizationEntry.objects.filter(
|
||||
plan__year=year,
|
||||
field_id=field_id,
|
||||
fertilizer_id=fertilizer_id,
|
||||
).update(actual_bags=total)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_spreading_session_side_effects(session, field_fertilizer_pairs):
|
||||
sync_actual_bags_for_pairs(session.year, field_fertilizer_pairs)
|
||||
sync_stock_uses_for_spreading_session(session)
|
||||
sync_spreading_work_record(session)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_stock_uses_for_spreading_session(session):
|
||||
StockTransaction.objects.filter(spreading_item__session=session).delete()
|
||||
|
||||
session_items = session.items.select_related('fertilizer__material')
|
||||
for item in session_items:
|
||||
material = getattr(item.fertilizer, 'material', None)
|
||||
if material is None:
|
||||
continue
|
||||
StockTransaction.objects.create(
|
||||
material=material,
|
||||
transaction_type=StockTransaction.TransactionType.USE,
|
||||
quantity=item.actual_bags,
|
||||
occurred_on=session.date,
|
||||
note=f'散布実績「{session.name.strip() or session.date}」',
|
||||
fertilization_plan=None,
|
||||
spreading_item=item,
|
||||
)
|
||||
|
||||
|
||||
def to_decimal_or_zero(value):
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
@@ -6,9 +6,11 @@ router = DefaultRouter()
|
||||
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||
path('spreading/candidates/', views.SpreadingCandidatesView.as_view(), name='spreading-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -12,15 +12,14 @@ from weasyprint import HTML
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.stock_service import (
|
||||
confirm_spreading as confirm_spreading_service,
|
||||
create_reserves_for_plan,
|
||||
delete_reserves_for_plan,
|
||||
unconfirm_spreading,
|
||||
)
|
||||
from apps.plans.models import Plan, Variety
|
||||
from apps.plans.models import Plan
|
||||
from .models import (
|
||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||
SpreadingSession, SpreadingSessionItem,
|
||||
)
|
||||
from .serializers import (
|
||||
FertilizerSerializer,
|
||||
@@ -29,7 +28,10 @@ from .serializers import (
|
||||
DeliveryPlanListSerializer,
|
||||
DeliveryPlanReadSerializer,
|
||||
DeliveryPlanWriteSerializer,
|
||||
SpreadingSessionSerializer,
|
||||
SpreadingSessionWriteSerializer,
|
||||
)
|
||||
from .services import sync_actual_bags_for_pairs
|
||||
|
||||
|
||||
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||
@@ -60,8 +62,6 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.is_confirmed:
|
||||
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
|
||||
instance = serializer.save()
|
||||
create_reserves_for_plan(instance)
|
||||
|
||||
@@ -123,68 +123,6 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='confirm_spreading')
|
||||
def confirm_spreading(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
|
||||
if plan.is_confirmed:
|
||||
return Response(
|
||||
{'detail': 'この計画は既に散布確定済みです。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
entries_data = request.data.get('entries', [])
|
||||
if not entries_data:
|
||||
return Response(
|
||||
{'detail': '実績データが空です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
actual_entries = []
|
||||
for entry in entries_data:
|
||||
field_id = entry.get('field_id')
|
||||
fertilizer_id = entry.get('fertilizer_id')
|
||||
if not field_id or not fertilizer_id:
|
||||
return Response(
|
||||
{'detail': 'field_id と fertilizer_id が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
|
||||
except InvalidOperation:
|
||||
return Response(
|
||||
{'detail': 'actual_bags は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
actual_entries.append(
|
||||
{
|
||||
'field_id': field_id,
|
||||
'fertilizer_id': fertilizer_id,
|
||||
'actual_bags': actual_bags,
|
||||
}
|
||||
)
|
||||
|
||||
confirm_spreading_service(plan, actual_entries)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='unconfirm')
|
||||
def unconfirm(self, request, pk=None):
|
||||
plan = self.get_object()
|
||||
|
||||
if not plan.is_confirmed:
|
||||
return Response(
|
||||
{'detail': 'この計画はまだ確定されていません。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
unconfirm_spreading(plan)
|
||||
plan.refresh_from_db()
|
||||
serializer = self.get_serializer(plan)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class CandidateFieldsView(APIView):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -421,3 +359,232 @@ class DeliveryPlanViewSet(viewsets.ModelViewSet):
|
||||
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SpreadingSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = SpreadingSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__fertilizer',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return SpreadingSessionWriteSerializer
|
||||
return SpreadingSessionSerializer
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
year = instance.year
|
||||
affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||
instance.delete()
|
||||
sync_actual_bags_for_pairs(year, affected_pairs)
|
||||
|
||||
|
||||
class SpreadingCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
session_id = request.query_params.get('session_id')
|
||||
delivery_plan_id = request.query_params.get('delivery_plan_id')
|
||||
plan_id = request.query_params.get('plan_id')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if delivery_plan_id:
|
||||
try:
|
||||
delivery_plan_id = int(delivery_plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'delivery_plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if plan_id:
|
||||
try:
|
||||
plan_id = int(plan_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'plan_id は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_session = None
|
||||
current_map = {}
|
||||
if session_id:
|
||||
try:
|
||||
current_session = SpreadingSession.objects.prefetch_related('items').get(
|
||||
pk=session_id,
|
||||
year=year,
|
||||
)
|
||||
except SpreadingSession.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': '散布実績が見つかりません。'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
current_map = {
|
||||
(item.field_id, item.fertilizer_id): {
|
||||
'actual_bags': item.actual_bags,
|
||||
'field_name': item.field.name,
|
||||
'field_area_tan': str(item.field.area_tan),
|
||||
'fertilizer_name': item.fertilizer.name,
|
||||
}
|
||||
for item in current_session.items.all()
|
||||
}
|
||||
|
||||
candidates = {}
|
||||
|
||||
plan_queryset = FertilizationEntry.objects.filter(plan__year=year)
|
||||
if plan_id:
|
||||
plan_queryset = plan_queryset.filter(plan_id=plan_id)
|
||||
plan_rows = (
|
||||
plan_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(planned_bags=Sum('bags'))
|
||||
)
|
||||
for row in plan_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['planned_bags'] = row['planned_bags'] or Decimal('0')
|
||||
|
||||
delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year)
|
||||
if delivery_plan_id:
|
||||
delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id)
|
||||
delivery_rows = delivery_queryset.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
).annotate(delivered_bags=Sum('bags'))
|
||||
for row in delivery_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['delivered_bags'] = row['delivered_bags'] or Decimal('0')
|
||||
|
||||
spread_queryset = SpreadingSessionItem.objects.filter(session__year=year)
|
||||
if current_session is not None:
|
||||
spread_queryset = spread_queryset.exclude(session=current_session)
|
||||
spread_rows = (
|
||||
spread_queryset
|
||||
.values(
|
||||
'field_id',
|
||||
'field__name',
|
||||
'field__area_tan',
|
||||
'fertilizer_id',
|
||||
'fertilizer__name',
|
||||
)
|
||||
.annotate(spread_bags=Sum('actual_bags'))
|
||||
)
|
||||
for row in spread_rows:
|
||||
key = (row['field_id'], row['fertilizer_id'])
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': row['field_id'],
|
||||
'field_name': row['field__name'],
|
||||
'field_area_tan': str(row['field__area_tan']),
|
||||
'fertilizer': row['fertilizer_id'],
|
||||
'fertilizer_name': row['fertilizer__name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['spread_bags'] = row['spread_bags'] or Decimal('0')
|
||||
|
||||
for key, current_data in current_map.items():
|
||||
candidates.setdefault(
|
||||
key,
|
||||
{
|
||||
'field': key[0],
|
||||
'field_name': current_data['field_name'],
|
||||
'field_area_tan': current_data['field_area_tan'],
|
||||
'fertilizer': key[1],
|
||||
'fertilizer_name': current_data['fertilizer_name'],
|
||||
'planned_bags': Decimal('0'),
|
||||
'delivered_bags': Decimal('0'),
|
||||
'spread_bags': Decimal('0'),
|
||||
'current_session_bags': Decimal('0'),
|
||||
},
|
||||
)['current_session_bags'] = current_data['actual_bags'] or Decimal('0')
|
||||
|
||||
rows = []
|
||||
for candidate in candidates.values():
|
||||
delivered = candidate['delivered_bags']
|
||||
planned = candidate['planned_bags']
|
||||
current_bags = candidate['current_session_bags']
|
||||
if delivery_plan_id:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
elif plan_id:
|
||||
include_row = planned > 0 or current_bags > 0
|
||||
else:
|
||||
include_row = delivered > 0 or current_bags > 0
|
||||
if not include_row:
|
||||
continue
|
||||
remaining = delivered - candidate['spread_bags']
|
||||
rows.append(
|
||||
{
|
||||
'field': candidate['field'],
|
||||
'field_name': candidate['field_name'],
|
||||
'field_area_tan': candidate['field_area_tan'],
|
||||
'fertilizer': candidate['fertilizer'],
|
||||
'fertilizer_name': candidate['fertilizer_name'],
|
||||
'planned_bags': str(planned),
|
||||
'delivered_bags': str(delivered),
|
||||
'spread_bags': str(candidate['spread_bags'] + current_bags),
|
||||
'spread_bags_other': str(candidate['spread_bags']),
|
||||
'current_session_bags': str(current_bags),
|
||||
'remaining_bags': str(remaining),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row['field_name'], row['fertilizer_name']))
|
||||
return Response(rows)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
('materials', '0002_stocktransaction_fertilization_plan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocktransaction',
|
||||
name='spreading_item',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stocktransaction',
|
||||
name='transaction_type',
|
||||
field=models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('reserve', '引当'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別'),
|
||||
),
|
||||
]
|
||||
@@ -205,6 +205,14 @@ class StockTransaction(models.Model):
|
||||
related_name='stock_reservations',
|
||||
verbose_name='施肥計画',
|
||||
)
|
||||
spreading_item = models.ForeignKey(
|
||||
'fertilizer.SpreadingSessionItem',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='stock_transactions',
|
||||
verbose_name='散布実績明細',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -14,9 +14,6 @@ def create_reserves_for_plan(plan):
|
||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||
).delete()
|
||||
|
||||
if plan.is_confirmed:
|
||||
return
|
||||
|
||||
occurred_on = (
|
||||
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
|
||||
)
|
||||
|
||||
1
backend/apps/workrecords/__init__.py
Normal file
1
backend/apps/workrecords/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
backend/apps/workrecords/admin.py
Normal file
11
backend/apps/workrecords/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
@admin.register(WorkRecord)
|
||||
class WorkRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ['work_date', 'work_type', 'title', 'year', 'auto_created']
|
||||
list_filter = ['work_type', 'year', 'auto_created']
|
||||
search_fields = ['title']
|
||||
|
||||
8
backend/apps/workrecords/apps.py
Normal file
8
backend/apps/workrecords/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkrecordsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.workrecords'
|
||||
verbose_name = '作業記録'
|
||||
|
||||
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
36
backend/apps/workrecords/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.0 on 2026-03-17 08:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('work_date', models.DateField(verbose_name='作業日')),
|
||||
('work_type', models.CharField(choices=[('fertilizer_delivery', '肥料運搬'), ('fertilizer_spreading', '肥料散布')], max_length=40, verbose_name='作業種別')),
|
||||
('title', models.CharField(max_length=200, verbose_name='タイトル')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('auto_created', models.BooleanField(default=True, verbose_name='自動生成')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('delivery_trip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.deliverytrip', verbose_name='運搬回')),
|
||||
('spreading_session', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_record', to='fertilizer.spreadingsession', verbose_name='散布実績')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '作業記録',
|
||||
'verbose_name_plural': '作業記録',
|
||||
'ordering': ['-work_date', '-updated_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/workrecords/migrations/__init__.py
Normal file
1
backend/apps/workrecords/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
44
backend/apps/workrecords/models.py
Normal file
44
backend/apps/workrecords/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WorkRecord(models.Model):
|
||||
class WorkType(models.TextChoices):
|
||||
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
||||
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
||||
|
||||
work_date = models.DateField(verbose_name='作業日')
|
||||
work_type = models.CharField(
|
||||
max_length=40,
|
||||
choices=WorkType.choices,
|
||||
verbose_name='作業種別',
|
||||
)
|
||||
title = models.CharField(max_length=200, verbose_name='タイトル')
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
auto_created = models.BooleanField(default=True, verbose_name='自動生成')
|
||||
delivery_trip = models.OneToOneField(
|
||||
'fertilizer.DeliveryTrip',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='work_record',
|
||||
verbose_name='運搬回',
|
||||
)
|
||||
spreading_session = models.OneToOneField(
|
||||
'fertilizer.SpreadingSession',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='work_record',
|
||||
verbose_name='散布実績',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-work_date', '-updated_at', '-id']
|
||||
verbose_name = '作業記録'
|
||||
verbose_name_plural = '作業記録'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.work_date} {self.get_work_type_display()}'
|
||||
|
||||
38
backend/apps/workrecords/serializers.py
Normal file
38
backend/apps/workrecords/serializers.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
class WorkRecordSerializer(serializers.ModelSerializer):
|
||||
work_type_display = serializers.CharField(source='get_work_type_display', read_only=True)
|
||||
delivery_plan_id = serializers.SerializerMethodField()
|
||||
delivery_plan_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkRecord
|
||||
fields = [
|
||||
'id',
|
||||
'work_date',
|
||||
'work_type',
|
||||
'work_type_display',
|
||||
'title',
|
||||
'year',
|
||||
'auto_created',
|
||||
'delivery_trip',
|
||||
'delivery_plan_id',
|
||||
'delivery_plan_name',
|
||||
'spreading_session',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_delivery_plan_id(self, obj):
|
||||
if obj.delivery_trip_id:
|
||||
return obj.delivery_trip.delivery_plan_id
|
||||
return None
|
||||
|
||||
def get_delivery_plan_name(self, obj):
|
||||
if obj.delivery_trip_id:
|
||||
return obj.delivery_trip.delivery_plan.name
|
||||
return None
|
||||
|
||||
33
backend/apps/workrecords/services.py
Normal file
33
backend/apps/workrecords/services.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from .models import WorkRecord
|
||||
|
||||
|
||||
def sync_delivery_work_record(trip):
|
||||
if trip.date is None:
|
||||
WorkRecord.objects.filter(delivery_trip=trip).delete()
|
||||
return
|
||||
|
||||
WorkRecord.objects.update_or_create(
|
||||
delivery_trip=trip,
|
||||
defaults={
|
||||
'work_date': trip.date,
|
||||
'work_type': WorkRecord.WorkType.FERTILIZER_DELIVERY,
|
||||
'title': f'肥料運搬: {trip.delivery_plan.name} {trip.order + 1}回目',
|
||||
'year': trip.delivery_plan.year,
|
||||
'auto_created': True,
|
||||
'spreading_session': None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sync_spreading_work_record(session):
|
||||
WorkRecord.objects.update_or_create(
|
||||
spreading_session=session,
|
||||
defaults={
|
||||
'work_date': session.date,
|
||||
'work_type': WorkRecord.WorkType.FERTILIZER_SPREADING,
|
||||
'title': f'肥料散布: {session.name.strip() or session.date}',
|
||||
'year': session.year,
|
||||
'auto_created': True,
|
||||
'delivery_trip': None,
|
||||
},
|
||||
)
|
||||
12
backend/apps/workrecords/urls.py
Normal file
12
backend/apps/workrecords/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import WorkRecordViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', WorkRecordViewSet, basename='workrecord')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
22
backend/apps/workrecords/views.py
Normal file
22
backend/apps/workrecords/views.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from .models import WorkRecord
|
||||
from .serializers import WorkRecordSerializer
|
||||
|
||||
|
||||
class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = WorkRecordSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = WorkRecord.objects.select_related(
|
||||
'delivery_trip',
|
||||
'delivery_trip__delivery_plan',
|
||||
'spreading_session',
|
||||
)
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
Reference in New Issue
Block a user