Compare commits
4 Commits
9dbbb48ee0
...
9f96d1f820
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f96d1f820 | ||
|
|
140d5e5a4d | ||
|
|
865d53ed9a | ||
|
|
c9ae99ebc8 |
@@ -65,7 +65,9 @@
|
|||||||
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
|
||||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
|
||||||
"Bash(git diff:*)",
|
"Bash(git diff:*)",
|
||||||
"mcp__serena__find_symbol"
|
"mcp__serena__find_symbol",
|
||||||
|
"mcp__serena__get_symbols_overview",
|
||||||
|
"Bash(git status:*)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
"C:\\Users\\akira\\AppData\\Local\\Temp",
|
||||||
|
|||||||
1
.serena/memories/project_overview.md
Normal file
1
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
keinasystem_t02 は農業生産者向けの作付け計画・圃場管理システム。主要スタックは Django/DRF/PostgreSQL(PostGIS) のバックエンドと Next.js 14 App Router + TypeScript + Tailwind CSS のフロントエンド。backend/apps に fields, plans, weather, reports, fertilizer, materials, mail があり、frontend/src/app に各画面がある。ドキュメント駆動で、CLAUDE.md と document/*.md が重要な仕様ソース。Windows 環境で Docker Compose による開発を前提としている。
|
||||||
1
.serena/memories/style_and_completion.md
Normal file
1
.serena/memories/style_and_completion.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
コードと仕様の変更はドキュメントドリブンで進める。仕様変更時は document 配下や CLAUDE.md の更新が重要。バックエンドは Django/DRF の標準的なモデル・serializer・viewset 構成、フロントは Next.js App Router と TypeScript。完了時は影響範囲に応じて少なくとも関連ドキュメント確認、必要な migration 確認、frontend lint (`npm run lint`) や対象 API/画面の動作確認を行う。既存の dirty worktree は勝手に戻さない。
|
||||||
1
.serena/memories/suggested_commands.md
Normal file
1
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Windows 環境の主要コマンド: `git status`, `rg <pattern>`, `Get-ChildItem`, `Get-Content <file>`, `docker compose -f docker-compose.develop.yml up -d`, `docker compose exec backend python manage.py migrate`, `docker compose exec backend python manage.py makemigrations`, `docker compose exec backend python manage.py runserver 0.0.0.0:8000`, `cd frontend; npm install; npm run dev`, `cd frontend; npm run lint`。開発用 compose では backend は `python manage.py runserver 0.0.0.0:8000`、frontend は `npm run dev` を利用する。
|
||||||
@@ -2,6 +2,7 @@ from django.contrib import admin
|
|||||||
from .models import (
|
from .models import (
|
||||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||||
|
SpreadingSession, SpreadingSessionItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ class FertilizationEntryInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(FertilizationPlan)
|
@admin.register(FertilizationPlan)
|
||||||
class FertilizationPlanAdmin(admin.ModelAdmin):
|
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'year', 'variety']
|
list_display = ['name', 'year', 'variety', 'is_confirmed', 'confirmed_at']
|
||||||
list_filter = ['year']
|
list_filter = ['year']
|
||||||
inlines = [FertilizationEntryInline]
|
inlines = [FertilizationEntryInline]
|
||||||
|
|
||||||
@@ -60,3 +61,15 @@ class DeliveryGroupAdmin(admin.ModelAdmin):
|
|||||||
class DeliveryTripAdmin(admin.ModelAdmin):
|
class DeliveryTripAdmin(admin.ModelAdmin):
|
||||||
list_display = ['delivery_plan', 'order', 'name', 'date']
|
list_display = ['delivery_plan', 'order', 'name', 'date']
|
||||||
inlines = [DeliveryTripItemInline]
|
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='肥料'
|
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
|
||||||
)
|
)
|
||||||
bags = models.DecimalField(max_digits=8, decimal_places=2, 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:
|
class Meta:
|
||||||
verbose_name = '施肥エントリ'
|
verbose_name = '施肥エントリ'
|
||||||
@@ -179,3 +186,63 @@ class DeliveryTripItem(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}袋"
|
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 rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.workrecords.services import sync_delivery_work_record
|
||||||
from .models import (
|
from .models import (
|
||||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
DeliveryGroup,
|
||||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
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):
|
class FertilizerSerializer(serializers.ModelSerializer):
|
||||||
@@ -36,7 +50,16 @@ class FertilizationEntrySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationEntry
|
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):
|
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||||
@@ -45,15 +68,34 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||||
field_count = serializers.SerializerMethodField()
|
field_count = serializers.SerializerMethodField()
|
||||||
fertilizer_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)
|
is_confirmed = serializers.BooleanField(read_only=True)
|
||||||
confirmed_at = serializers.DateTimeField(read_only=True)
|
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationPlan
|
model = FertilizationPlan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
'id',
|
||||||
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
|
'name',
|
||||||
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
|
'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):
|
def get_variety_name(self, obj):
|
||||||
@@ -68,9 +110,32 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
def get_fertilizer_count(self, obj):
|
def get_fertilizer_count(self, obj):
|
||||||
return obj.entries.values('fertilizer').distinct().count()
|
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):
|
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
"""保存用(entries を一括で受け取る)"""
|
|
||||||
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -80,7 +145,8 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
entries_data = validated_data.pop('entries', [])
|
entries_data = validated_data.pop('entries', [])
|
||||||
plan = FertilizationPlan.objects.create(**validated_data)
|
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
|
return plan
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@@ -90,21 +156,23 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
|||||||
instance.save()
|
instance.save()
|
||||||
if entries_data is not None:
|
if entries_data is not None:
|
||||||
instance.entries.all().delete()
|
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
|
return instance
|
||||||
|
|
||||||
def _save_entries(self, plan, entries_data):
|
def _save_entries(self, plan, entries_data):
|
||||||
|
pairs = set()
|
||||||
for entry in entries_data:
|
for entry in entries_data:
|
||||||
|
pairs.add((entry['field_id'], entry['fertilizer_id']))
|
||||||
FertilizationEntry.objects.create(
|
FertilizationEntry.objects.create(
|
||||||
plan=plan,
|
plan=plan,
|
||||||
field_id=entry['field_id'],
|
field_id=entry['field_id'],
|
||||||
fertilizer_id=entry['fertilizer_id'],
|
fertilizer_id=entry['fertilizer_id'],
|
||||||
bags=entry['bags'],
|
bags=entry['bags'],
|
||||||
)
|
)
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
# ─── 運搬計画 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(source='field.id', read_only=True)
|
id = serializers.IntegerField(source='field.id', read_only=True)
|
||||||
name = serializers.CharField(source='field.name', read_only=True)
|
name = serializers.CharField(source='field.name', read_only=True)
|
||||||
@@ -128,18 +196,51 @@ class DeliveryGroupReadSerializer(serializers.ModelSerializer):
|
|||||||
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
class DeliveryTripItemSerializer(serializers.ModelSerializer):
|
||||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||||
|
spread_bags = serializers.SerializerMethodField()
|
||||||
|
remaining_bags = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeliveryTripItem
|
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):
|
class DeliveryTripReadSerializer(serializers.ModelSerializer):
|
||||||
items = DeliveryTripItemSerializer(many=True, read_only=True)
|
items = DeliveryTripItemSerializer(many=True, read_only=True)
|
||||||
|
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeliveryTrip
|
model = DeliveryTrip
|
||||||
fields = ['id', 'order', 'name', 'date', 'items']
|
fields = ['id', 'order', 'name', 'date', 'work_record_id', 'items']
|
||||||
|
|
||||||
|
|
||||||
class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
||||||
@@ -149,8 +250,13 @@ class DeliveryPlanListSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeliveryPlan
|
model = DeliveryPlan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'year', 'name', 'group_count', 'trip_count',
|
'id',
|
||||||
'created_at', 'updated_at',
|
'year',
|
||||||
|
'name',
|
||||||
|
'group_count',
|
||||||
|
'trip_count',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_group_count(self, obj):
|
def get_group_count(self, obj):
|
||||||
@@ -170,20 +276,27 @@ class DeliveryPlanReadSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeliveryPlan
|
model = DeliveryPlan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'year', 'name', 'groups', 'trips',
|
'id',
|
||||||
'unassigned_fields', 'available_fertilizers', 'all_entries',
|
'year',
|
||||||
'created_at', 'updated_at',
|
'name',
|
||||||
|
'groups',
|
||||||
|
'trips',
|
||||||
|
'unassigned_fields',
|
||||||
|
'available_fertilizers',
|
||||||
|
'all_entries',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_unassigned_fields(self, obj):
|
def get_unassigned_fields(self, obj):
|
||||||
assigned_ids = DeliveryGroupField.objects.filter(
|
assigned_ids = DeliveryGroupField.objects.filter(
|
||||||
delivery_plan=obj
|
delivery_plan=obj
|
||||||
).values_list('field_id', flat=True)
|
).values_list('field_id', flat=True)
|
||||||
# 年度の施肥計画に含まれる全圃場
|
|
||||||
plan_field_ids = FertilizationEntry.objects.filter(
|
plan_field_ids = FertilizationEntry.objects.filter(
|
||||||
plan__year=obj.year
|
plan__year=obj.year
|
||||||
).values_list('field_id', flat=True).distinct()
|
).values_list('field_id', flat=True).distinct()
|
||||||
from apps.fields.models import Field
|
from apps.fields.models import Field
|
||||||
|
|
||||||
unassigned = Field.objects.filter(
|
unassigned = Field.objects.filter(
|
||||||
id__in=plan_field_ids
|
id__in=plan_field_ids
|
||||||
).exclude(id__in=assigned_ids).order_by('display_order', 'id')
|
).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]
|
return [{'id': f.id, 'name': f.name} for f in fertilizers]
|
||||||
|
|
||||||
def get_all_entries(self, obj):
|
def get_all_entries(self, obj):
|
||||||
"""年度の全施肥計画のエントリ(フロントで袋数計算に使用)"""
|
|
||||||
entries = FertilizationEntry.objects.filter(
|
entries = FertilizationEntry.objects.filter(
|
||||||
plan__year=obj.year
|
plan__year=obj.year
|
||||||
).select_related('field', 'fertilizer')
|
).select_related('field', 'fertilizer')
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'field': e.field_id,
|
'field': entry.field_id,
|
||||||
'field_name': e.field.name,
|
'field_name': entry.field.name,
|
||||||
'field_area_tan': str(e.field.area_tan),
|
'field_area_tan': str(entry.field.area_tan),
|
||||||
'fertilizer': e.fertilizer_id,
|
'fertilizer': entry.fertilizer_id,
|
||||||
'fertilizer_name': e.fertilizer.name,
|
'fertilizer_name': entry.fertilizer.name,
|
||||||
'bags': str(e.bags),
|
'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
|
return instance
|
||||||
|
|
||||||
def _save_groups(self, plan, groups_data):
|
def _save_groups(self, plan, groups_data):
|
||||||
for g_data in groups_data:
|
for group_data in groups_data:
|
||||||
group = DeliveryGroup.objects.create(
|
group = DeliveryGroup.objects.create(
|
||||||
delivery_plan=plan,
|
delivery_plan=plan,
|
||||||
name=g_data['name'],
|
name=group_data['name'],
|
||||||
order=g_data.get('order', 0),
|
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(
|
DeliveryGroupField.objects.create(
|
||||||
delivery_plan=plan,
|
delivery_plan=plan,
|
||||||
group=group,
|
group=group,
|
||||||
@@ -259,17 +372,116 @@ class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _save_trips(self, plan, trips_data):
|
def _save_trips(self, plan, trips_data):
|
||||||
for t_data in trips_data:
|
for trip_data in trips_data:
|
||||||
trip = DeliveryTrip.objects.create(
|
trip = DeliveryTrip.objects.create(
|
||||||
delivery_plan=plan,
|
delivery_plan=plan,
|
||||||
order=t_data.get('order', 0),
|
order=trip_data.get('order', 0),
|
||||||
name=t_data.get('name', ''),
|
name=trip_data.get('name', ''),
|
||||||
date=t_data.get('date'),
|
date=trip_data.get('date'),
|
||||||
)
|
)
|
||||||
for item in t_data.get('items', []):
|
for item in trip_data.get('items', []):
|
||||||
DeliveryTripItem.objects.create(
|
DeliveryTripItem.objects.create(
|
||||||
trip=trip,
|
trip=trip,
|
||||||
field_id=item['field_id'],
|
field_id=item['field_id'],
|
||||||
fertilizer_id=item['fertilizer_id'],
|
fertilizer_id=item['fertilizer_id'],
|
||||||
bags=item['bags'],
|
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
|
||||||
|
|||||||
58
backend/apps/fertilizer/services.py
Normal file
58
backend/apps/fertilizer/services.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
@@ -6,9 +6,11 @@ router = DefaultRouter()
|
|||||||
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||||
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||||
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
|
||||||
|
router.register(r'spreading', views.SpreadingSessionViewSet, basename='spreading-session')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
|
||||||
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||||
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
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 decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from django.db.models import Sum
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -12,15 +12,14 @@ from weasyprint import HTML
|
|||||||
|
|
||||||
from apps.fields.models import Field
|
from apps.fields.models import Field
|
||||||
from apps.materials.stock_service import (
|
from apps.materials.stock_service import (
|
||||||
confirm_spreading as confirm_spreading_service,
|
|
||||||
create_reserves_for_plan,
|
create_reserves_for_plan,
|
||||||
delete_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 (
|
from .models import (
|
||||||
Fertilizer, FertilizationPlan, FertilizationEntry,
|
Fertilizer, FertilizationPlan, FertilizationEntry,
|
||||||
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
|
||||||
|
SpreadingSession, SpreadingSessionItem,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
FertilizerSerializer,
|
FertilizerSerializer,
|
||||||
@@ -29,7 +28,10 @@ from .serializers import (
|
|||||||
DeliveryPlanListSerializer,
|
DeliveryPlanListSerializer,
|
||||||
DeliveryPlanReadSerializer,
|
DeliveryPlanReadSerializer,
|
||||||
DeliveryPlanWriteSerializer,
|
DeliveryPlanWriteSerializer,
|
||||||
|
SpreadingSessionSerializer,
|
||||||
|
SpreadingSessionWriteSerializer,
|
||||||
)
|
)
|
||||||
|
from .services import sync_actual_bags_for_pairs
|
||||||
|
|
||||||
|
|
||||||
class FertilizerViewSet(viewsets.ModelViewSet):
|
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||||
@@ -60,8 +62,6 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
create_reserves_for_plan(instance)
|
create_reserves_for_plan(instance)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
if serializer.instance.is_confirmed:
|
|
||||||
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
|
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
create_reserves_for_plan(instance)
|
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"'
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
return response
|
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):
|
class CandidateFieldsView(APIView):
|
||||||
"""作付け計画から圃場候補を返す"""
|
"""作付け計画から圃場候補を返す"""
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -421,3 +359,236 @@ class DeliveryPlanViewSet(viewsets.ModelViewSet):
|
|||||||
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
|
||||||
)
|
)
|
||||||
return response
|
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):
|
||||||
|
from apps.materials.models import StockTransaction
|
||||||
|
year = instance.year
|
||||||
|
affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()}
|
||||||
|
StockTransaction.objects.filter(spreading_item__session=instance).delete()
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
delivery_queryset = delivery_queryset.filter(trip__date__isnull=False)
|
||||||
|
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='取引種別'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-03-17 10:44
|
||||||
|
|
||||||
|
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', '0003_stocktransaction_spreading_item_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stocktransaction',
|
||||||
|
name='spreading_item',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -205,6 +205,14 @@ class StockTransaction(models.Model):
|
|||||||
related_name='stock_reservations',
|
related_name='stock_reservations',
|
||||||
verbose_name='施肥計画',
|
verbose_name='施肥計画',
|
||||||
)
|
)
|
||||||
|
spreading_item = models.ForeignKey(
|
||||||
|
'fertilizer.SpreadingSessionItem',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='stock_transactions',
|
||||||
|
verbose_name='散布実績明細',
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ def create_reserves_for_plan(plan):
|
|||||||
transaction_type=StockTransaction.TransactionType.RESERVE,
|
transaction_type=StockTransaction.TransactionType.RESERVE,
|
||||||
).delete()
|
).delete()
|
||||||
|
|
||||||
if plan.is_confirmed:
|
|
||||||
return
|
|
||||||
|
|
||||||
occurred_on = (
|
occurred_on = (
|
||||||
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
|
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
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.weather',
|
'apps.weather',
|
||||||
'apps.fertilizer',
|
'apps.fertilizer',
|
||||||
'apps.materials',
|
'apps.materials',
|
||||||
|
'apps.workrecords',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -59,4 +59,5 @@ urlpatterns = [
|
|||||||
path('api/weather/', include('apps.weather.urls')),
|
path('api/weather/', include('apps.weather.urls')),
|
||||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
path('api/materials/', include('apps.materials.urls')),
|
path('api/materials/', include('apps.materials.urls')),
|
||||||
|
path('api/workrecords/', include('apps.workrecords.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft } from 'lucide-react';
|
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft, Sprout } from 'lucide-react';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { DeliveryPlan, DeliveryAllEntry } from '@/types';
|
import { DeliveryPlan, DeliveryAllEntry } from '@/types';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -676,9 +676,20 @@ export default function DeliveryEditPage({ planId }: Props) {
|
|||||||
運搬計画一覧
|
運搬計画一覧
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-xl font-bold text-gray-900 mb-6">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
|
<h1 className="text-xl font-bold text-gray-900">
|
||||||
</h1>
|
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
|
||||||
|
</h1>
|
||||||
|
{isEdit && planId && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/fertilizer/spreading?year=${year}&delivery_plan=${planId}`)}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
<Sprout className="h-4 w-4" />
|
||||||
|
この運搬計画から散布実績へ進む
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
|
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
|
import { Truck, Plus, FileDown, Pencil, Trash2, X, Sprout } from 'lucide-react';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { DeliveryPlanListItem } from '@/types';
|
import { DeliveryPlanListItem } from '@/types';
|
||||||
@@ -75,13 +75,22 @@ export default function DeliveryListPage() {
|
|||||||
<Truck className="h-7 w-7 text-green-700" />
|
<Truck className="h-7 w-7 text-green-700" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900">運搬計画</h1>
|
<h1 className="text-2xl font-bold text-gray-900">運搬計画</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => router.push('/distribution/new')}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
|
onClick={() => router.push('/fertilizer/spreading')}
|
||||||
>
|
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
新規作成
|
<Sprout className="h-4 w-4" />
|
||||||
</button>
|
散布実績
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/distribution/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規作成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 年度セレクタ */}
|
{/* 年度セレクタ */}
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Loader2, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { FertilizationPlan } from '@/types';
|
|
||||||
|
|
||||||
interface ConfirmSpreadingModalProps {
|
|
||||||
plan: FertilizationPlan | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirmed: () => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActualMap = Record<string, string>;
|
|
||||||
|
|
||||||
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
|
|
||||||
type EntryMatrix = Record<number, Record<number, string>>;
|
|
||||||
|
|
||||||
export default function ConfirmSpreadingModal({
|
|
||||||
plan,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirmed,
|
|
||||||
}: ConfirmSpreadingModalProps) {
|
|
||||||
const [actuals, setActuals] = useState<ActualMap>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !plan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextActuals: ActualMap = {};
|
|
||||||
plan.entries.forEach((entry) => {
|
|
||||||
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
|
|
||||||
});
|
|
||||||
setActuals(nextActuals);
|
|
||||||
setError(null);
|
|
||||||
}, [isOpen, plan]);
|
|
||||||
|
|
||||||
const layout = useMemo(() => {
|
|
||||||
if (!plan) {
|
|
||||||
return {
|
|
||||||
fields: [] as { id: number; name: string; areaTan: string | undefined }[],
|
|
||||||
fertilizers: [] as { id: number; name: string }[],
|
|
||||||
planned: {} as EntryMatrix,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldMap = new Map<number, { id: number; name: string; areaTan: string | undefined }>();
|
|
||||||
const fertilizerMap = new Map<number, { id: number; name: string }>();
|
|
||||||
const planned: EntryMatrix = {};
|
|
||||||
|
|
||||||
plan.entries.forEach((entry) => {
|
|
||||||
if (!fieldMap.has(entry.field)) {
|
|
||||||
fieldMap.set(entry.field, {
|
|
||||||
id: entry.field,
|
|
||||||
name: entry.field_name ?? `圃場ID:${entry.field}`,
|
|
||||||
areaTan: entry.field_area_tan,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!fertilizerMap.has(entry.fertilizer)) {
|
|
||||||
fertilizerMap.set(entry.fertilizer, {
|
|
||||||
id: entry.fertilizer,
|
|
||||||
name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!planned[entry.field]) {
|
|
||||||
planned[entry.field] = {};
|
|
||||||
}
|
|
||||||
planned[entry.field][entry.fertilizer] = String(entry.bags);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
fields: Array.from(fieldMap.values()),
|
|
||||||
fertilizers: Array.from(fertilizerMap.values()),
|
|
||||||
planned,
|
|
||||||
};
|
|
||||||
}, [plan]);
|
|
||||||
|
|
||||||
if (!isOpen || !plan) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
|
|
||||||
entries: plan.entries.map((entry) => ({
|
|
||||||
field_id: entry.field,
|
|
||||||
fertilizer_id: entry.fertilizer,
|
|
||||||
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await onConfirmed();
|
|
||||||
onClose();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const detail =
|
|
||||||
typeof e === 'object' &&
|
|
||||||
e !== null &&
|
|
||||||
'response' in e &&
|
|
||||||
typeof e.response === 'object' &&
|
|
||||||
e.response !== null &&
|
|
||||||
'data' in e.response
|
|
||||||
? JSON.stringify(e.response.data)
|
|
||||||
: '散布確定に失敗しました。';
|
|
||||||
setError(detail);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const numericValue = (value: string | undefined) => {
|
|
||||||
const parsed = parseFloat(value ?? '0');
|
|
||||||
return isNaN(parsed) ? 0 : parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actualTotalByField = (fieldId: number) =>
|
|
||||||
layout.fertilizers.reduce(
|
|
||||||
(sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const actualTotalByFertilizer = (fertilizerId: number) =>
|
|
||||||
layout.fields.reduce(
|
|
||||||
(sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
|
|
||||||
<div className="max-h-[92vh] w-full max-w-[95vw] overflow-hidden rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
散布確定: 「{plan.name}」
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
施肥計画編集と同じ並びで、各セルの計画値を確認しながら実績数量を入力します。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[calc(92vh-144px)] overflow-y-auto bg-gray-50 px-6 py-5">
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
|
||||||
<div className="grid gap-3 text-sm text-gray-700 sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500">年度</div>
|
|
||||||
<div className="font-medium">{plan.year}年度</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500">作物 / 品種</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{plan.crop_name} / {plan.variety_name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500">対象圃場</div>
|
|
||||||
<div className="font-medium">{plan.field_count}筆</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500">肥料数</div>
|
|
||||||
<div className="font-medium">{plan.fertilizer_count}種</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-800">
|
|
||||||
各セルの灰色表示が計画値、入力欄が散布実績です。「0」を入力したセルは未散布として引当解除されます。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
|
||||||
<table className="min-w-full text-sm border-collapse">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-200 px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap">
|
|
||||||
圃場名
|
|
||||||
</th>
|
|
||||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
|
||||||
面積(反)
|
|
||||||
</th>
|
|
||||||
{layout.fertilizers.map((fertilizer) => (
|
|
||||||
<th
|
|
||||||
key={fertilizer.id}
|
|
||||||
className="border border-gray-200 px-3 py-2 text-center font-medium text-gray-700 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<div>{fertilizer.name}</div>
|
|
||||||
<div className="mt-0.5 text-[11px] font-normal text-gray-400">
|
|
||||||
計画 / 実績
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
|
|
||||||
実績合計
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{layout.fields.map((field) => (
|
|
||||||
<tr key={field.id} className="hover:bg-gray-50">
|
|
||||||
<td className="border border-gray-200 px-4 py-2 whitespace-nowrap text-gray-800">
|
|
||||||
{field.name}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-600 whitespace-nowrap">
|
|
||||||
{field.areaTan ?? '-'}
|
|
||||||
</td>
|
|
||||||
{layout.fertilizers.map((fertilizer) => {
|
|
||||||
const key = entryKey(field.id, fertilizer.id);
|
|
||||||
const planned = layout.planned[field.id]?.[fertilizer.id];
|
|
||||||
const hasEntry = planned !== undefined;
|
|
||||||
return (
|
|
||||||
<td key={key} className="border border-gray-200 px-2 py-2">
|
|
||||||
{hasEntry ? (
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<span className="text-[11px] text-gray-400">
|
|
||||||
計画 {planned}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
value={actuals[key] ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setActuals((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-gray-300">-</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-700">
|
|
||||||
{actualTotalByField(field.id).toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot className="bg-gray-50 font-semibold">
|
|
||||||
<tr>
|
|
||||||
<td className="border border-gray-200 px-4 py-2">合計</td>
|
|
||||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
|
|
||||||
{layout.fields
|
|
||||||
.reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0)
|
|
||||||
.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
{layout.fertilizers.map((fertilizer) => (
|
|
||||||
<td
|
|
||||||
key={fertilizer.id}
|
|
||||||
className="border border-gray-200 px-3 py-2 text-right text-gray-700"
|
|
||||||
>
|
|
||||||
{actualTotalByFertilizer(fertilizer.id).toFixed(2)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="border border-gray-200 px-3 py-2 text-right text-green-700">
|
|
||||||
{layout.fields
|
|
||||||
.reduce((sum, field) => sum + actualTotalByField(field.id), 0)
|
|
||||||
.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
キャンセル
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
一括確定
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
|
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Sprout } from 'lucide-react';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
|
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
|
||||||
@@ -62,11 +62,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
// roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用)
|
// roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用)
|
||||||
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||||||
const [adjusted, setAdjusted] = useState<Matrix>({});
|
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||||||
|
const [actualMatrix, setActualMatrix] = useState<Matrix>({});
|
||||||
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
||||||
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
|
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
|
||||||
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
|
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
|
||||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
|
||||||
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(!isNew);
|
const [loading, setLoading] = useState(!isNew);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -102,9 +101,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
setName(plan.name);
|
setName(plan.name);
|
||||||
setYear(plan.year);
|
setYear(plan.year);
|
||||||
setVarietyId(plan.variety);
|
setVarietyId(plan.variety);
|
||||||
setIsConfirmed(plan.is_confirmed);
|
|
||||||
setConfirmedAt(plan.confirmed_at);
|
|
||||||
|
|
||||||
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||||
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||||
setPlanFertilizers(ferts);
|
setPlanFertilizers(ferts);
|
||||||
@@ -122,11 +118,17 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// 保存済みの値は adjusted に復元
|
// 保存済みの値は adjusted に復元
|
||||||
const newAdjusted: Matrix = {};
|
const newAdjusted: Matrix = {};
|
||||||
|
const newActualMatrix: Matrix = {};
|
||||||
plan.entries.forEach((e) => {
|
plan.entries.forEach((e) => {
|
||||||
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
|
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
|
||||||
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
||||||
|
if (e.actual_bags !== null && e.actual_bags !== undefined) {
|
||||||
|
if (!newActualMatrix[e.field]) newActualMatrix[e.field] = {};
|
||||||
|
newActualMatrix[e.field][e.fertilizer] = String(e.actual_bags);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setAdjusted(newAdjusted);
|
setAdjusted(newAdjusted);
|
||||||
|
setActualMatrix(newActualMatrix);
|
||||||
setInitialPlanTotals(
|
setInitialPlanTotals(
|
||||||
plan.entries.reduce((acc: Record<number, number>, entry) => {
|
plan.entries.reduce((acc: Record<number, number>, entry) => {
|
||||||
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
|
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
|
||||||
@@ -195,7 +197,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 肥料追加・削除
|
// ─── 肥料追加・削除
|
||||||
const addFertilizer = (fert: Fertilizer) => {
|
const addFertilizer = (fert: Fertilizer) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||||
setPlanFertilizers((prev) => [...prev, fert]);
|
setPlanFertilizers((prev) => [...prev, fert]);
|
||||||
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||||
@@ -203,7 +205,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeFertilizer = (id: number) => {
|
const removeFertilizer = (id: number) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||||
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||||
const dropCol = (m: Matrix): Matrix => {
|
const dropCol = (m: Matrix): Matrix => {
|
||||||
@@ -222,14 +224,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 圃場追加・削除
|
// ─── 圃場追加・削除
|
||||||
const addField = (field: Field) => {
|
const addField = (field: Field) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
if (selectedFields.find((f) => f.id === field.id)) return;
|
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||||
setSelectedFields((prev) => [...prev, field]);
|
setSelectedFields((prev) => [...prev, field]);
|
||||||
setShowFieldPicker(false);
|
setShowFieldPicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeField = (id: number) => {
|
const removeField = (id: number) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||||
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||||
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||||
@@ -239,7 +241,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 自動計算
|
// ─── 自動計算
|
||||||
const runCalc = async (setting: CalcSetting) => {
|
const runCalc = async (setting: CalcSetting) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
if (!setting.param) {
|
if (!setting.param) {
|
||||||
setSaveError('パラメータを入力してください');
|
setSaveError('パラメータを入力してください');
|
||||||
return;
|
return;
|
||||||
@@ -298,7 +300,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── セル更新(adjusted を更新)
|
// ─── セル更新(adjusted を更新)
|
||||||
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
setAdjusted((prev) => {
|
setAdjusted((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (!next[fieldId]) next[fieldId] = {};
|
if (!next[fieldId]) next[fieldId] = {};
|
||||||
@@ -309,7 +311,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
||||||
const roundColumn = (fertId: number) => {
|
const roundColumn = (fertId: number) => {
|
||||||
if (isConfirmed) return;
|
|
||||||
if (roundedColumns.has(fertId)) {
|
if (roundedColumns.has(fertId)) {
|
||||||
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
||||||
setAdjusted((prev) => {
|
setAdjusted((prev) => {
|
||||||
@@ -383,10 +385,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
if (isConfirmed) {
|
|
||||||
setSaveError('確定済みの施肥計画は編集できません。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
||||||
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
||||||
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
|
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
|
||||||
@@ -427,31 +425,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 確定取消
|
|
||||||
const handleUnconfirm = async () => {
|
|
||||||
if (!planId) return;
|
|
||||||
setSaveError(null);
|
|
||||||
try {
|
|
||||||
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
|
|
||||||
setIsConfirmed(false);
|
|
||||||
setConfirmedAt(null);
|
|
||||||
// 引当が再作成されるので在庫情報を再取得
|
|
||||||
const stockRes = await api.get('/materials/fertilizer-stock/');
|
|
||||||
setStockByMaterialId(
|
|
||||||
stockRes.data.reduce(
|
|
||||||
(acc: Record<number, StockSummary>, summary: StockSummary) => {
|
|
||||||
acc[summary.material_id] = summary;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setSaveError('確定取消に失敗しました');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── PDF出力
|
// ─── PDF出力
|
||||||
const handlePdf = async () => {
|
const handlePdf = async () => {
|
||||||
if (!planId) return;
|
if (!planId) return;
|
||||||
@@ -498,13 +471,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isNew && isConfirmed && (
|
{!isNew && planId && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUnconfirm}
|
onClick={() => router.push(`/fertilizer/spreading?year=${year}&plan=${planId}`)}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
|
className="flex items-center gap-2 px-4 py-2 border border-emerald-300 rounded-lg text-sm text-emerald-700 hover:bg-emerald-50"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Sprout className="h-4 w-4" />
|
||||||
確定取消
|
この施肥計画から散布実績へ進む
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
@@ -518,11 +491,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || isConfirmed}
|
disabled={saving}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
|
{saving ? '保存中...' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,16 +509,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfirmed && (
|
|
||||||
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
|
|
||||||
<span className="font-bold shrink-0">i</span>
|
|
||||||
<span>
|
|
||||||
この施肥計画は散布確定済みです。
|
|
||||||
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 基本情報 */}
|
{/* 基本情報 */}
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||||
<div className="flex-1 min-w-48">
|
<div className="flex-1 min-w-48">
|
||||||
@@ -555,7 +518,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||||
disabled={isConfirmed}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -564,7 +527,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
disabled={isConfirmed}
|
|
||||||
>
|
>
|
||||||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -575,7 +538,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
value={varietyId}
|
value={varietyId}
|
||||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
disabled={isConfirmed}
|
|
||||||
>
|
>
|
||||||
<option value="">品種を選択</option>
|
<option value="">品種を選択</option>
|
||||||
{crops.map((crop) => (
|
{crops.map((crop) => (
|
||||||
@@ -601,7 +564,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFieldPicker(true)}
|
onClick={() => setShowFieldPicker(true)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />圃場を追加
|
<Plus className="h-3 w-3" />圃場を追加
|
||||||
@@ -621,7 +584,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
{f.name}({f.area_tan}反)
|
{f.name}({f.area_tan}反)
|
||||||
<button
|
<button
|
||||||
onClick={() => removeField(f.id)}
|
onClick={() => removeField(f.id)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -641,14 +604,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={calcNewOnly}
|
checked={calcNewOnly}
|
||||||
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
未入力圃場のみ
|
未入力圃場のみ
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFertPicker(true)}
|
onClick={() => setShowFertPicker(true)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />肥料を追加
|
<Plus className="h-3 w-3" />肥料を追加
|
||||||
@@ -671,7 +634,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
value={setting.method}
|
value={setting.method}
|
||||||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||||
disabled={isConfirmed}
|
|
||||||
>
|
>
|
||||||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||||
<option key={k} value={k}>{v}</option>
|
<option key={k} value={k}>{v}</option>
|
||||||
@@ -684,19 +647,19 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
value={setting.param}
|
value={setting.param}
|
||||||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||||
placeholder="値"
|
placeholder="値"
|
||||||
disabled={isConfirmed}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => runCalc(setting)}
|
onClick={() => runCalc(setting)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Calculator className="h-3 w-3" />計算
|
<Calculator className="h-3 w-3" />計算
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFertilizer(fert.id)}
|
onClick={() => removeFertilizer(fert.id)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -750,7 +713,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
(袋)
|
(袋)
|
||||||
<button
|
<button
|
||||||
onClick={() => roundColumn(f.id)}
|
onClick={() => roundColumn(f.id)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
||||||
isRounded
|
isRounded
|
||||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||||
@@ -775,25 +738,33 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
{planFertilizers.map((fert) => {
|
{planFertilizers.map((fert) => {
|
||||||
const calcVal = calcMatrix[field.id]?.[fert.id];
|
const calcVal = calcMatrix[field.id]?.[fert.id];
|
||||||
const adjVal = adjusted[field.id]?.[fert.id];
|
const adjVal = adjusted[field.id]?.[fert.id];
|
||||||
|
const actualVal = actualMatrix[field.id]?.[fert.id];
|
||||||
// 計算結果があればラベルを表示(adjusted が上書きされた場合は参照値として)
|
// 計算結果があればラベルを表示(adjusted が上書きされた場合は参照値として)
|
||||||
const showRef = calcVal !== undefined;
|
const showRef = calcVal !== undefined;
|
||||||
// 入力欄: adjusted → calc値 → 空
|
// 入力欄: adjusted → calc値 → 空
|
||||||
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
|
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
|
||||||
return (
|
return (
|
||||||
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="space-y-1">
|
||||||
{showRef && (
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
{showRef && (
|
||||||
|
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||||
|
placeholder="-"
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{actualVal !== undefined && (
|
||||||
|
<div className="text-right text-[11px] text-sky-700">
|
||||||
|
実績 {actualVal}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
|
||||||
placeholder="-"
|
|
||||||
disabled={isConfirmed}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -841,7 +812,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addField(f)}
|
onClick={() => addField(f)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span>{f.name}</span>
|
<span>{f.name}</span>
|
||||||
@@ -856,7 +827,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addField(f)}
|
onClick={() => addField(f)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span>{f.name}</span>
|
<span>{f.name}</span>
|
||||||
@@ -884,7 +855,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addFertilizer(f)}
|
onClick={() => addFertilizer(f)}
|
||||||
disabled={isConfirmed}
|
|
||||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{f.name}</span>
|
<span className="font-medium">{f.name}</span>
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
|
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react';
|
||||||
|
|
||||||
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
|
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { FertilizationPlan } from '@/types';
|
import { FertilizationPlan } from '@/types';
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<FertilizationPlan['spread_status'], string> = {
|
||||||
|
unspread: '未散布',
|
||||||
|
partial: '一部散布',
|
||||||
|
completed: '散布済み',
|
||||||
|
over_applied: '超過散布',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CLASSES: Record<FertilizationPlan['spread_status'], string> = {
|
||||||
|
unspread: 'bg-gray-100 text-gray-700',
|
||||||
|
partial: 'bg-amber-100 text-amber-800',
|
||||||
|
completed: 'bg-emerald-100 text-emerald-800',
|
||||||
|
over_applied: 'bg-rose-100 text-rose-800',
|
||||||
|
};
|
||||||
|
|
||||||
export default function FertilizerPage() {
|
export default function FertilizerPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [year, setYear] = useState<number>(() => {
|
const [year, setYear] = useState<number>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const saved = localStorage.getItem('fertilizerYear');
|
const saved = localStorage.getItem('fertilizerYear');
|
||||||
if (saved) return parseInt(saved);
|
if (saved) return parseInt(saved, 10);
|
||||||
}
|
}
|
||||||
return currentYear;
|
return currentYear;
|
||||||
});
|
});
|
||||||
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
|
||||||
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('fertilizerYear', String(year));
|
localStorage.setItem('fertilizerYear', String(year));
|
||||||
@@ -33,41 +44,31 @@ export default function FertilizerPage() {
|
|||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
||||||
setPlans(res.data);
|
setPlans(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
setError('施肥計画の読み込みに失敗しました。');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number, name: string) => {
|
const handleDelete = async (id: number, name: string) => {
|
||||||
setDeleteError(null);
|
setError(null);
|
||||||
setActionError(null);
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/fertilizer/plans/${id}/`);
|
await api.delete(`/fertilizer/plans/${id}/`);
|
||||||
await fetchPlans();
|
await fetchPlans();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setDeleteError(`「${name}」の削除に失敗しました`);
|
setError(`「${name}」の削除に失敗しました。`);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnconfirm = async (id: number, name: string) => {
|
|
||||||
setActionError(null);
|
|
||||||
try {
|
|
||||||
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
|
|
||||||
await fetchPlans();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setActionError(`「${name}」の確定取消に失敗しました`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePdf = async (id: number, name: string) => {
|
const handlePdf = async (id: number, name: string) => {
|
||||||
setActionError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
@@ -78,7 +79,7 @@ export default function FertilizerPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setActionError('PDF出力に失敗しました');
|
setError('PDF出力に失敗しました。');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,22 +88,36 @@ export default function FertilizerPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Sprout className="h-6 w-6 text-green-600" />
|
<Sprout className="h-6 w-6 text-green-600" />
|
||||||
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/workrecords')}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<NotebookText className="h-4 w-4" />
|
||||||
|
作業記録
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/spreading')}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
散布実績
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/fertilizer/masters')}
|
onClick={() => router.push('/fertilizer/masters')}
|
||||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
肥料マスタ
|
肥料マスタ
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/fertilizer/new')}
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
新規作成
|
新規作成
|
||||||
@@ -110,113 +125,76 @@ export default function FertilizerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 年度セレクタ */}
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
<select
|
<select
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
>
|
>
|
||||||
{years.map((y) => (
|
{years.map((y) => (
|
||||||
<option key={y} value={y}>{y}年度</option>
|
<option key={y} value={y}>
|
||||||
|
{y}年度
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deleteError && (
|
{error && (
|
||||||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
<span className="font-bold shrink-0">⚠</span>
|
{error}
|
||||||
<span>{deleteError}</span>
|
|
||||||
<button onClick={() => setDeleteError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionError && (
|
|
||||||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
|
||||||
<span className="font-bold shrink-0">⚠</span>
|
|
||||||
<span>{actionError}</span>
|
|
||||||
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">読み込み中...</p>
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
) : plans.length === 0 ? (
|
) : plans.length === 0 ? (
|
||||||
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
|
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
|
||||||
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||||
<p>{year}年度の施肥計画はありません</p>
|
<p>{year}年度の施肥計画はありません</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/fertilizer/new')}
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
className="mt-4 rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-700"
|
||||||
>
|
>
|
||||||
最初の計画を作成する
|
最初の計画を作成する
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b">
|
<thead className="border-b bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-700">計画名</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-700">計画名</th>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-700">作物 / 品種</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-700">作物 / 品種</th>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-700">状態</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-700">散布状況</th>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-700">圃場数</th>
|
<th className="px-4 py-3 text-right font-medium text-gray-700">計画</th>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-700">肥料種数</th>
|
<th className="px-4 py-3 text-right font-medium text-gray-700">実績</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3 text-right font-medium text-gray-700">残</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-700">圃場</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<tr
|
<tr key={plan.id} className="hover:bg-gray-50">
|
||||||
key={plan.id}
|
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
|
||||||
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{plan.name}</span>
|
|
||||||
{plan.is_confirmed && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
|
|
||||||
<BadgeCheck className="h-3.5 w-3.5" />
|
|
||||||
確定済み
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{plan.crop_name} / {plan.variety_name}
|
{plan.crop_name} / {plan.variety_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600">
|
|
||||||
{plan.is_confirmed
|
|
||||||
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
|
|
||||||
: '未確定'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_CLASSES[plan.spread_status]}`}>
|
||||||
{!plan.is_confirmed ? (
|
{STATUS_LABELS[plan.spread_status]}
|
||||||
<button
|
</span>
|
||||||
onClick={() => setConfirmTarget(plan)}
|
</td>
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
|
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.planned_total_bags}</td>
|
||||||
title="散布確定"
|
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.spread_total_bags}</td>
|
||||||
>
|
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.remaining_total_bags}</td>
|
||||||
<BadgeCheck className="h-3.5 w-3.5" />
|
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||||
散布確定
|
<td className="px-4 py-3">
|
||||||
</button>
|
<div className="flex items-center justify-end gap-2">
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnconfirm(plan.id, plan.name)}
|
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
|
|
||||||
title="確定取消"
|
|
||||||
>
|
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
|
||||||
確定取消
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePdf(plan.id, plan.name)}
|
onClick={() => handlePdf(plan.id, plan.name)}
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
|
className="flex items-center gap-1 rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
title="PDF出力"
|
title="PDF出力"
|
||||||
>
|
>
|
||||||
<FileDown className="h-3.5 w-3.5" />
|
<FileDown className="h-3.5 w-3.5" />
|
||||||
@@ -224,7 +202,7 @@ export default function FertilizerPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
|
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||||
title="編集"
|
title="編集"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
@@ -232,7 +210,7 @@ export default function FertilizerPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(plan.id, plan.name)}
|
onClick={() => handleDelete(plan.id, plan.name)}
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
|
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||||
title="削除"
|
title="削除"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -247,13 +225,6 @@ export default function FertilizerPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmSpreadingModal
|
|
||||||
isOpen={confirmTarget !== null}
|
|
||||||
plan={confirmTarget}
|
|
||||||
onClose={() => setConfirmTarget(null)}
|
|
||||||
onConfirmed={fetchPlans}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
747
frontend/src/app/fertilizer/spreading/page.tsx
Normal file
747
frontend/src/app/fertilizer/spreading/page.tsx
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
DeliveryPlan,
|
||||||
|
FertilizationPlan,
|
||||||
|
SpreadingCandidate,
|
||||||
|
SpreadingSession,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
const YEAR_KEY = 'spreadingYear';
|
||||||
|
|
||||||
|
type SourceType = 'delivery' | 'plan' | 'year';
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
notes: string;
|
||||||
|
itemValues: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixField = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
area_tan: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixFertilizer = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`;
|
||||||
|
|
||||||
|
const toNumber = (value: string | number | null | undefined) => {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDisplay = (value: string | number | null | undefined) => {
|
||||||
|
const num = toNumber(value);
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
return String(num);
|
||||||
|
}
|
||||||
|
return num.toFixed(4).replace(/\.?0+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatInputValue = (value: number) => {
|
||||||
|
if (value <= 0) return '0';
|
||||||
|
return value.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultDate = (year: number) => {
|
||||||
|
const today = new Date();
|
||||||
|
if (today.getFullYear() !== year) {
|
||||||
|
return `${year}-01-01`;
|
||||||
|
}
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => {
|
||||||
|
if (deliveryPlanId) return 'delivery';
|
||||||
|
if (fertilizationPlanId) return 'plan';
|
||||||
|
return 'year';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
rows.forEach((candidate) => {
|
||||||
|
let base = 0;
|
||||||
|
if (sourceType === 'delivery') {
|
||||||
|
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
|
||||||
|
} else if (sourceType === 'plan') {
|
||||||
|
base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other);
|
||||||
|
} else {
|
||||||
|
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
|
||||||
|
}
|
||||||
|
values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0));
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SpreadingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const queryYear = Number(searchParams.get('year') || '0') || null;
|
||||||
|
const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null;
|
||||||
|
const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null;
|
||||||
|
const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId);
|
||||||
|
|
||||||
|
const [year, setYear] = useState<number>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||||
|
}
|
||||||
|
return CURRENT_YEAR;
|
||||||
|
});
|
||||||
|
const [sessions, setSessions] = useState<SpreadingSession[]>([]);
|
||||||
|
const [candidates, setCandidates] = useState<SpreadingCandidate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
const [openedFromQuery, setOpenedFromQuery] = useState(false);
|
||||||
|
const [openedFromSource, setOpenedFromSource] = useState(false);
|
||||||
|
const [sourceName, setSourceName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryYear && queryYear !== year) {
|
||||||
|
setYear(queryYear);
|
||||||
|
}
|
||||||
|
}, [queryYear, year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(YEAR_KEY, String(year));
|
||||||
|
void fetchSessions();
|
||||||
|
setForm(null);
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setOpenedFromQuery(false);
|
||||||
|
setOpenedFromSource(false);
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSource = async () => {
|
||||||
|
if (deliveryPlanId) {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`);
|
||||||
|
const plan: DeliveryPlan = res.data;
|
||||||
|
setSourceName(plan.name);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setSourceName(`運搬計画 #${deliveryPlanId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fertilizationPlanId) {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
|
||||||
|
const plan: FertilizationPlan = res.data;
|
||||||
|
setSourceName(plan.name);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setSourceName(`施肥計画 #${fertilizationPlanId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSourceName(null);
|
||||||
|
};
|
||||||
|
void loadSource();
|
||||||
|
}, [deliveryPlanId, fertilizationPlanId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionParam = searchParams.get('session');
|
||||||
|
if (!sessionParam || openedFromQuery || sessions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetId = Number(sessionParam);
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = sessions.find((session) => session.id === targetId);
|
||||||
|
if (target) {
|
||||||
|
void openEditor(target);
|
||||||
|
setOpenedFromQuery(true);
|
||||||
|
}
|
||||||
|
}, [openedFromQuery, searchParams, sessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionParam = searchParams.get('session');
|
||||||
|
if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void startCreate();
|
||||||
|
setOpenedFromSource(true);
|
||||||
|
}, [form, formLoading, openedFromSource, searchParams, sourceType]);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/spreading/?year=${year}`);
|
||||||
|
setSessions(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('散布実績の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCandidates = async (sessionId?: number) => {
|
||||||
|
const params = new URLSearchParams({ year: String(year) });
|
||||||
|
if (sessionId) {
|
||||||
|
params.set('session_id', String(sessionId));
|
||||||
|
}
|
||||||
|
if (deliveryPlanId) {
|
||||||
|
params.set('delivery_plan_id', String(deliveryPlanId));
|
||||||
|
}
|
||||||
|
if (fertilizationPlanId) {
|
||||||
|
params.set('plan_id', String(fertilizationPlanId));
|
||||||
|
}
|
||||||
|
const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`);
|
||||||
|
setCandidates(res.data);
|
||||||
|
return res.data as SpreadingCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreate = async () => {
|
||||||
|
setFormLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const loaded = await loadCandidates();
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setForm({
|
||||||
|
date: getDefaultDate(year),
|
||||||
|
name: '',
|
||||||
|
notes: '',
|
||||||
|
itemValues: buildCreateInitialValues(loaded, sourceType),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('散布候補の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditor = async (session: SpreadingSession) => {
|
||||||
|
setFormLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await loadCandidates(session.id);
|
||||||
|
const itemValues = session.items.reduce<Record<string, string>>((acc, item) => {
|
||||||
|
acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
setEditingSessionId(session.id);
|
||||||
|
setForm({
|
||||||
|
date: session.date,
|
||||||
|
name: session.name,
|
||||||
|
notes: session.notes,
|
||||||
|
itemValues,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('散布候補の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setForm(null);
|
||||||
|
setCandidates([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidateMap = useMemo(() => {
|
||||||
|
const map = new Map<string, SpreadingCandidate>();
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
map.set(candidateKey(candidate.field, candidate.fertilizer), candidate);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [candidates]);
|
||||||
|
|
||||||
|
const matrixFields = useMemo<MatrixField[]>(() => {
|
||||||
|
const map = new Map<number, MatrixField>();
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
if (!map.has(candidate.field)) {
|
||||||
|
map.set(candidate.field, {
|
||||||
|
id: candidate.field,
|
||||||
|
name: candidate.field_name,
|
||||||
|
area_tan: candidate.field_area_tan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
|
||||||
|
}, [candidates]);
|
||||||
|
|
||||||
|
const matrixFertilizers = useMemo<MatrixFertilizer[]>(() => {
|
||||||
|
const map = new Map<number, MatrixFertilizer>();
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
if (!map.has(candidate.fertilizer)) {
|
||||||
|
map.set(candidate.fertilizer, {
|
||||||
|
id: candidate.fertilizer,
|
||||||
|
name: candidate.fertilizer_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
|
||||||
|
}, [candidates]);
|
||||||
|
|
||||||
|
const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => {
|
||||||
|
if (!form) return;
|
||||||
|
const key = candidateKey(fieldId, fertilizerId);
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
itemValues: {
|
||||||
|
...form.itemValues,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellValue = (fieldId: number, fertilizerId: number) => {
|
||||||
|
if (!form) return '';
|
||||||
|
return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRows = useMemo(() => {
|
||||||
|
if (!form) return [];
|
||||||
|
return candidates.filter((candidate) => {
|
||||||
|
const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
|
||||||
|
return value > 0;
|
||||||
|
});
|
||||||
|
}, [candidates, form]);
|
||||||
|
|
||||||
|
const getRowTotal = (fieldId: number) => {
|
||||||
|
if (!form) return 0;
|
||||||
|
return matrixFertilizers.reduce((sum, fertilizer) => {
|
||||||
|
const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id));
|
||||||
|
if (!candidate) return sum;
|
||||||
|
return sum + toNumber(getCellValue(fieldId, fertilizer.id));
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnTotal = (fertilizerId: number) => {
|
||||||
|
if (!form) return 0;
|
||||||
|
return matrixFields.reduce((sum, field) => {
|
||||||
|
const candidate = candidateMap.get(candidateKey(field.id, fertilizerId));
|
||||||
|
if (!candidate) return sum;
|
||||||
|
return sum + toNumber(getCellValue(field.id, fertilizerId));
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalInputBags = selectedRows.reduce((sum, candidate) => {
|
||||||
|
return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
setError(null);
|
||||||
|
if (!form.date) {
|
||||||
|
setError('散布日を入力してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = selectedRows.map((candidate) => ({
|
||||||
|
field_id: candidate.field,
|
||||||
|
fertilizer_id: candidate.fertilizer,
|
||||||
|
actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'),
|
||||||
|
planned_bags_snapshot: toNumber(candidate.planned_bags),
|
||||||
|
delivered_bags_snapshot: toNumber(candidate.delivered_bags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
setError('散布実績を1件以上入力してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
year,
|
||||||
|
date: form.date,
|
||||||
|
name: form.name,
|
||||||
|
notes: form.notes,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
if (editingSessionId) {
|
||||||
|
await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/fertilizer/spreading/', payload);
|
||||||
|
}
|
||||||
|
await fetchSessions();
|
||||||
|
closeEditor();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('散布実績の保存に失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (sessionId: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.delete(`/fertilizer/spreading/${sessionId}/`);
|
||||||
|
await fetchSessions();
|
||||||
|
if (editingSessionId === sessionId) {
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('散布実績の削除に失敗しました。');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||||
|
|
||||||
|
const sourceSummary =
|
||||||
|
sourceType === 'delivery'
|
||||||
|
? '初期値は運搬計画値から散布済を引いた値です。'
|
||||||
|
: sourceType === 'plan'
|
||||||
|
? '初期値は施肥計画値から散布済を引いた値です。'
|
||||||
|
: '初期値は運搬済みから散布済を引いた値です。';
|
||||||
|
|
||||||
|
const sourceLabel =
|
||||||
|
sourceType === 'delivery'
|
||||||
|
? '運搬計画を選択した状態です'
|
||||||
|
: sourceType === 'plan'
|
||||||
|
? '施肥計画を選択した状態です'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const clearFilterHref = `/fertilizer/spreading?year=${year}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<Sprout className="h-6 w-6 text-green-700" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">散布実績</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void startCreate()}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規記録
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}年度
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceLabel && (
|
||||||
|
<div className="mb-6 flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-emerald-900">{sourceLabel}</div>
|
||||||
|
<div className="mt-1 text-sm text-emerald-700">
|
||||||
|
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)}
|
||||||
|
{' '}を起点に散布候補を絞り込んでいます。
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-emerald-700">{sourceSummary}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(clearFilterHref)}
|
||||||
|
className="flex items-center gap-1 rounded border border-emerald-300 px-3 py-1.5 text-xs text-emerald-700 hover:bg-emerald-100"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
絞り込み解除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(form || formLoading) && (
|
||||||
|
<section className="mb-8 rounded-lg border border-emerald-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-emerald-100 px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{editingSessionId ? '散布実績を編集' : '散布実績を登録'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
施肥計画と同じ感覚で、圃場 × 肥料のマトリックスで実績を入力します。
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{sourceSummary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formLoading || !form ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-gray-500">候補を読み込み中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5 px-5 py-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">散布日</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">名称</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="例: 3/17 元肥散布"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||||
|
<input
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="任意"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||||
|
<table className="min-w-full border-collapse text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="w-48 border border-gray-200 px-4 py-3 text-left font-medium text-gray-700">
|
||||||
|
圃場
|
||||||
|
</th>
|
||||||
|
{matrixFertilizers.map((fertilizer) => (
|
||||||
|
<th
|
||||||
|
key={fertilizer.id}
|
||||||
|
className="min-w-[220px] border border-gray-200 px-3 py-3 text-center font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
<div>{fertilizer.name}</div>
|
||||||
|
<div className="mt-1 text-[11px] font-normal text-gray-400">
|
||||||
|
入力計 {formatDisplay(getColumnTotal(fertilizer.id))}袋
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="w-28 border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
|
||||||
|
行合計
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{matrixFields.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={matrixFertilizers.length + 2}
|
||||||
|
className="border border-gray-200 px-4 py-8 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
散布対象の候補がありません。
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
matrixFields.map((field) => (
|
||||||
|
<tr key={field.id} className="hover:bg-gray-50">
|
||||||
|
<td className="border border-gray-200 px-4 py-3 align-top">
|
||||||
|
<div className="font-medium text-gray-900">{field.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{field.area_tan}反</div>
|
||||||
|
</td>
|
||||||
|
{matrixFertilizers.map((fertilizer) => {
|
||||||
|
const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id));
|
||||||
|
if (!candidate) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={fertilizer.id}
|
||||||
|
className="border border-gray-200 bg-gray-50 px-3 py-3 text-center text-xs text-gray-300"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={fertilizer.id} className="border border-gray-200 px-3 py-3 align-top">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1 text-[11px] leading-5 text-gray-500">
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<span className="mr-1 text-gray-400">計画</span>
|
||||||
|
<span>{formatDisplay(candidate.planned_bags)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<span className="mr-1 text-gray-400">
|
||||||
|
{sourceType === 'plan' ? '計画残' : '未散布'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatDisplay(
|
||||||
|
sourceType === 'plan'
|
||||||
|
? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0)
|
||||||
|
: Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<span className="mr-1 text-gray-400">運搬</span>
|
||||||
|
<span>{formatDisplay(candidate.delivered_bags)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<span className="mr-1 text-gray-400">散布済</span>
|
||||||
|
<span>{formatDisplay(candidate.spread_bags_other)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={getCellValue(field.id, fertilizer.id)}
|
||||||
|
onChange={(e) => handleItemChange(field.id, fertilizer.id, e.target.value)}
|
||||||
|
className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
|
||||||
|
{formatDisplay(getRowTotal(field.id))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
{matrixFields.length > 0 && (
|
||||||
|
<tfoot className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-200 px-4 py-3 font-medium text-gray-700">合計</td>
|
||||||
|
{matrixFertilizers.map((fertilizer) => (
|
||||||
|
<td
|
||||||
|
key={fertilizer.id}
|
||||||
|
className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{formatDisplay(getColumnTotal(fertilizer.id))}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="border border-gray-200 px-3 py-3 text-right font-bold text-green-700">
|
||||||
|
{formatDisplay(totalInputBags)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
入力中 {selectedRows.length}件 / 合計 {formatDisplay(totalInputBags)}袋
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={closeEditor}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rounded-lg bg-white shadow-sm">
|
||||||
|
<div className="border-b px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">登録済み散布実績</h2>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-gray-400">この年度の散布実績はまだありません。</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">散布日</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">名称</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-700">明細数</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-700">合計袋数</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-700">作業記録</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0);
|
||||||
|
return (
|
||||||
|
<tr key={session.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-gray-700">{session.date}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-900">{session.name || '名称なし'}</div>
|
||||||
|
{session.notes && <div className="text-xs text-gray-400">{session.notes}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{session.items.length}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{formatDisplay(totalBags)}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{session.work_record_id ? `#${session.work_record_id}` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void openEditor(session)}
|
||||||
|
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
編集
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleDelete(session.id)}
|
||||||
|
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/app/workrecords/page.tsx
Normal file
138
frontend/src/app/workrecords/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ChevronLeft, NotebookText } from 'lucide-react';
|
||||||
|
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { WorkRecord } from '@/types';
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
const YEAR_KEY = 'workRecordYear';
|
||||||
|
|
||||||
|
export default function WorkRecordsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [year, setYear] = useState<number>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||||
|
}
|
||||||
|
return CURRENT_YEAR;
|
||||||
|
});
|
||||||
|
const [records, setRecords] = useState<WorkRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(YEAR_KEY, String(year));
|
||||||
|
void fetchRecords();
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/workrecords/?year=${year}`);
|
||||||
|
setRecords(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('作業記録の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToSource = (record: WorkRecord) => {
|
||||||
|
if (record.spreading_session) {
|
||||||
|
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record.delivery_plan_id) {
|
||||||
|
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<NotebookText className="h-6 w-6 text-green-700" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">作業記録</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}年度
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg bg-white shadow-sm">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-gray-400">この年度の作業記録はまだありません。</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">作業日</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">種別</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">タイトル</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">参照先</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{records.map((record) => (
|
||||||
|
<tr key={record.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-gray-700">{record.work_date}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{record.work_type_display}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">{record.title}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{record.spreading_session
|
||||||
|
? `散布実績 #${record.spreading_session}`
|
||||||
|
: record.delivery_plan_name
|
||||||
|
? `${record.delivery_plan_name}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{(record.spreading_session || record.delivery_plan_id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => moveToSource(record)}
|
||||||
|
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
開く
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package } from 'lucide-react';
|
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -114,7 +114,7 @@ export default function Navbar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push('/fertilizer')}
|
onClick={() => router.push('/fertilizer')}
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
pathname?.startsWith('/fertilizer')
|
pathname?.startsWith('/fertilizer') && !pathname?.startsWith('/fertilizer/spreading')
|
||||||
? 'text-green-700 bg-green-50'
|
? 'text-green-700 bg-green-50'
|
||||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
@@ -122,6 +122,17 @@ export default function Navbar() {
|
|||||||
<Sprout className="h-4 w-4 mr-2" />
|
<Sprout className="h-4 w-4 mr-2" />
|
||||||
施肥計画
|
施肥計画
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/spreading')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/fertilizer/spreading')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PencilLine className="h-4 w-4 mr-2" />
|
||||||
|
散布実績
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/distribution')}
|
onClick={() => router.push('/distribution')}
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
@@ -144,6 +155,17 @@ export default function Navbar() {
|
|||||||
<Package className="h-4 w-4 mr-2" />
|
<Package className="h-4 w-4 mr-2" />
|
||||||
在庫管理
|
在庫管理
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/workrecords')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/workrecords')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<NotebookText className="h-4 w-4 mr-2" />
|
||||||
|
作業記録
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ export interface FertilizationEntry {
|
|||||||
field_area_tan?: string;
|
field_area_tan?: string;
|
||||||
fertilizer: number;
|
fertilizer: number;
|
||||||
fertilizer_name?: string;
|
fertilizer_name?: string;
|
||||||
bags: number;
|
bags: number | string;
|
||||||
|
actual_bags?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationPlan {
|
export interface FertilizationPlan {
|
||||||
@@ -154,6 +155,10 @@ export interface FertilizationPlan {
|
|||||||
entries: FertilizationEntry[];
|
entries: FertilizationEntry[];
|
||||||
field_count: number;
|
field_count: number;
|
||||||
fertilizer_count: number;
|
fertilizer_count: number;
|
||||||
|
planned_total_bags: string;
|
||||||
|
spread_total_bags: string;
|
||||||
|
remaining_total_bags: string;
|
||||||
|
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
|
||||||
is_confirmed: boolean;
|
is_confirmed: boolean;
|
||||||
confirmed_at: string | null;
|
confirmed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -180,6 +185,8 @@ export interface DeliveryTripItem {
|
|||||||
fertilizer: number;
|
fertilizer: number;
|
||||||
fertilizer_name: string;
|
fertilizer_name: string;
|
||||||
bags: string;
|
bags: string;
|
||||||
|
spread_bags: string;
|
||||||
|
remaining_bags: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryTrip {
|
export interface DeliveryTrip {
|
||||||
@@ -187,6 +194,7 @@ export interface DeliveryTrip {
|
|||||||
order: number;
|
order: number;
|
||||||
name: string;
|
name: string;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
|
work_record_id: number | null;
|
||||||
items: DeliveryTripItem[];
|
items: DeliveryTripItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +205,7 @@ export interface DeliveryAllEntry {
|
|||||||
fertilizer: number;
|
fertilizer: number;
|
||||||
fertilizer_name: string;
|
fertilizer_name: string;
|
||||||
bags: string;
|
bags: string;
|
||||||
|
actual_bags?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryPlan {
|
export interface DeliveryPlan {
|
||||||
@@ -222,6 +231,59 @@ export interface DeliveryPlanListItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpreadingCandidate {
|
||||||
|
field: number;
|
||||||
|
field_name: string;
|
||||||
|
field_area_tan: string;
|
||||||
|
fertilizer: number;
|
||||||
|
fertilizer_name: string;
|
||||||
|
planned_bags: string;
|
||||||
|
delivered_bags: string;
|
||||||
|
spread_bags: string;
|
||||||
|
spread_bags_other: string;
|
||||||
|
current_session_bags: string;
|
||||||
|
remaining_bags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadingSessionItem {
|
||||||
|
id: number;
|
||||||
|
field: number;
|
||||||
|
field_name: string;
|
||||||
|
fertilizer: number;
|
||||||
|
fertilizer_name: string;
|
||||||
|
actual_bags: string;
|
||||||
|
planned_bags_snapshot: string;
|
||||||
|
delivered_bags_snapshot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadingSession {
|
||||||
|
id: number;
|
||||||
|
year: number;
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
notes: string;
|
||||||
|
work_record_id: number | null;
|
||||||
|
items: SpreadingSessionItem[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkRecord {
|
||||||
|
id: number;
|
||||||
|
work_date: string;
|
||||||
|
work_type: 'fertilizer_delivery' | 'fertilizer_spreading';
|
||||||
|
work_type_display: string;
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
auto_created: boolean;
|
||||||
|
delivery_trip: number | null;
|
||||||
|
delivery_plan_id: number | null;
|
||||||
|
delivery_plan_name: string | null;
|
||||||
|
spreading_session: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MailSender {
|
export interface MailSender {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'address' | 'domain';
|
type: 'address' | 'domain';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
623
改善案/施肥散布実績連携変更実装仕様.md
Normal file
623
改善案/施肥散布実績連携変更実装仕様.md
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
# 施肥散布実績連携変更実装仕様
|
||||||
|
|
||||||
|
> 作成日: 2026-03-17
|
||||||
|
> 改訂日: 2026-03-17
|
||||||
|
> 対象プロジェクト: `keinasystem_t02`
|
||||||
|
> 位置づけ: 実運用反映版・MVP仕様
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 結論
|
||||||
|
|
||||||
|
今回の変更では、施肥データの流れを以下の一気通貫に揃える。
|
||||||
|
|
||||||
|
1. 施肥計画を作る
|
||||||
|
2. 施肥計画を元に運搬計画を作る
|
||||||
|
3. 運搬済み肥料を元に散布実績を記録する
|
||||||
|
4. 散布実績を元に作業記録として参照できる
|
||||||
|
5. 将来、必要な相手先提出資料へ変換できる
|
||||||
|
|
||||||
|
MVPの方針は以下とする。
|
||||||
|
|
||||||
|
- 施肥計画の `散布確定` ボタンは廃止する
|
||||||
|
- 散布実績は `SpreadingSession` と `SpreadingSessionItem` で管理する
|
||||||
|
- `SpreadingAllocation` は作らない
|
||||||
|
- 在庫の `USE` は散布実績保存時に発生させる
|
||||||
|
- `WorkRecord` は作るが、明細の二重管理はしない
|
||||||
|
- `WorkRecord` は `DeliveryTrip` と `SpreadingSession` への索引として使う
|
||||||
|
- `FertilizationEntry.bags` は計画値として維持し、`actual_bags` を実績集計値として別保持する
|
||||||
|
- 客先提出PDFは今回実装しない
|
||||||
|
- ただし、将来どの様式にも変換できるよう、元データを引き出せる構造にする
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 背景
|
||||||
|
|
||||||
|
現状実装では、`FertilizationPlan` に「散布確定」ボタンがあり、施肥計画単位で一括確定する流れになっている。
|
||||||
|
|
||||||
|
しかし実運用では、実際に必要なのは以下である。
|
||||||
|
|
||||||
|
- どの圃場に
|
||||||
|
- どの肥料を
|
||||||
|
- どれだけ散布したか
|
||||||
|
- それがいつ行われたか
|
||||||
|
|
||||||
|
また、データは以下のように流用できる必要がある。
|
||||||
|
|
||||||
|
- 施肥計画作成
|
||||||
|
- 運搬計画作成
|
||||||
|
- 散布実績記録
|
||||||
|
- 作業記録参照
|
||||||
|
- 相手先提出資料への転用
|
||||||
|
|
||||||
|
この流れが切れると、従来どおり別DBや別表へ転記が必要になり、システム化の価値が大きく下がる。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 今回の判断
|
||||||
|
|
||||||
|
### 3.1 採用するもの
|
||||||
|
|
||||||
|
- `SpreadingSession` と `SpreadingSessionItem`
|
||||||
|
- `WorkRecord` の新設
|
||||||
|
- 散布保存時の在庫 `USE` 登録
|
||||||
|
- `FertilizationEntry.actual_bags` の集計反映
|
||||||
|
- 施肥計画一覧からの `散布確定` UI 廃止
|
||||||
|
- 施肥計画進捗の自動集計表示
|
||||||
|
|
||||||
|
### 3.2 今回は見送るもの
|
||||||
|
|
||||||
|
- `SpreadingAllocation`
|
||||||
|
- `DeliveryTripItem.fertilization_entry` FK
|
||||||
|
- 客先提出PDFの固定様式実装
|
||||||
|
|
||||||
|
### 3.3 理由
|
||||||
|
|
||||||
|
- `SpreadingAllocation` が必要になる運用は今後も発生しない前提である
|
||||||
|
- `WorkRecord` は必要だが、詳細を二重保持するのではなく索引で十分である
|
||||||
|
- 在庫は実際に減るため、散布実績と同時に管理すべきである
|
||||||
|
- 提出資料は相手先ごとに違うため、今固定様式を作るより、元データ抽出可能性を優先すべきである
|
||||||
|
- 運搬計画は年度ベースで複数施肥計画を横断するため、`DeliveryTripItem` に単一の `fertilization_entry` FK を持たせるのは不安がある
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 機能スコープ
|
||||||
|
|
||||||
|
### 4.1 IN
|
||||||
|
|
||||||
|
- 施肥計画の `散布確定` ボタン廃止
|
||||||
|
- 散布実績モデル追加
|
||||||
|
- 作業記録索引モデル追加
|
||||||
|
- 在庫 `USE` 連携
|
||||||
|
- `FertilizationEntry.actual_bags` 集計
|
||||||
|
- 散布実績一覧・作成・更新・削除 API
|
||||||
|
- 散布候補集計 API
|
||||||
|
- 施肥計画進捗表示
|
||||||
|
- 前年度コピー時の `actual_bags → bags` 初期化反映
|
||||||
|
- 作業記録一覧から運搬・散布の実績を参照可能にすること
|
||||||
|
|
||||||
|
### 4.2 OUT
|
||||||
|
|
||||||
|
- 運搬便ごとの散布充当追跡
|
||||||
|
- 相手先ごとのPDF様式実装
|
||||||
|
- 残肥返却・再入庫管理
|
||||||
|
- カレンダーUIの完成版
|
||||||
|
- 栽培管理全体を包含した汎用作業日誌の完成版
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 新しい業務フロー
|
||||||
|
|
||||||
|
### 5.1 施肥計画
|
||||||
|
|
||||||
|
1. 施肥計画を作成する
|
||||||
|
2. 計画値として保持する
|
||||||
|
3. この段階では散布確定しない
|
||||||
|
|
||||||
|
### 5.2 運搬計画
|
||||||
|
|
||||||
|
1. 施肥計画を元に運搬計画を作成する
|
||||||
|
2. `DeliveryTrip.date` に運搬日を入れる
|
||||||
|
3. 日付が入った運搬回を `運搬済み` とみなす
|
||||||
|
4. 運搬日が保存されたら `WorkRecord` を自動生成または更新する
|
||||||
|
|
||||||
|
### 5.3 散布実績
|
||||||
|
|
||||||
|
1. ユーザーは散布日を選ぶ
|
||||||
|
2. システムはその年度の `運搬済み` データを集計する
|
||||||
|
3. `未散布残` がある圃場×肥料を候補として表示する
|
||||||
|
4. ユーザーは全部または一部の圃場を選ぶ
|
||||||
|
5. 実際に散布した袋数を入力して保存する
|
||||||
|
6. 保存時に在庫 `USE` を作成する
|
||||||
|
7. 保存時に `WorkRecord` を自動生成または更新する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. データモデル
|
||||||
|
|
||||||
|
### 6.1 `FertilizationPlan`
|
||||||
|
|
||||||
|
既存の `is_confirmed` / `confirmed_at` は中心機能ではなくなる。
|
||||||
|
|
||||||
|
#### 方針
|
||||||
|
|
||||||
|
- DBカラムは当面残す
|
||||||
|
- 新UIでは `confirm_spreading` / `unconfirm` を使わない
|
||||||
|
- 一覧では散布進捗を計算表示する
|
||||||
|
|
||||||
|
#### 追加する表示用項目
|
||||||
|
|
||||||
|
- `spread_status`: `unspread | partial | completed | over_applied`
|
||||||
|
- `planned_total_bags`
|
||||||
|
- `spread_total_bags`
|
||||||
|
- `remaining_total_bags`
|
||||||
|
|
||||||
|
#### `FertilizationEntry.actual_bags` の追加
|
||||||
|
|
||||||
|
`FertilizationEntry` に以下のフィールドを追加する。
|
||||||
|
|
||||||
|
- `actual_bags = DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)`
|
||||||
|
|
||||||
|
#### 役割
|
||||||
|
|
||||||
|
- `bags`: 計画値
|
||||||
|
- `actual_bags`: 散布実績の集計値
|
||||||
|
|
||||||
|
`bags` はユーザーが立てた計画として保持し、散布後も自動で上書きしない。
|
||||||
|
実績は `actual_bags` に集約する。
|
||||||
|
|
||||||
|
#### `actual_bags` の更新契機
|
||||||
|
|
||||||
|
- `SpreadingSessionItem` 作成時
|
||||||
|
- `SpreadingSessionItem` 更新時
|
||||||
|
- `SpreadingSessionItem` 削除時
|
||||||
|
|
||||||
|
上記のたびに、同一年度・圃場・肥料の散布実績合計を再集計して反映する。
|
||||||
|
|
||||||
|
### 6.2 `DeliveryTrip` / `DeliveryTripItem`
|
||||||
|
|
||||||
|
MVPではモデル追加は行わない。
|
||||||
|
|
||||||
|
#### 前提
|
||||||
|
|
||||||
|
- `DeliveryTrip.date != null` の明細のみを `運搬済み` とみなす
|
||||||
|
- `DeliveryTripItem` は今までどおり `field + fertilizer + bags` を保持する
|
||||||
|
- `fertilization_entry` FK は追加しない
|
||||||
|
|
||||||
|
### 6.3 `SpreadingSession`(新規)
|
||||||
|
|
||||||
|
散布日の親レコード。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| year | int | required | 年度フィルタ用 |
|
||||||
|
| date | DateField | required | 散布日 |
|
||||||
|
| name | varchar(100) | blank | 任意名 |
|
||||||
|
| notes | text | blank | 備考 |
|
||||||
|
| created_at / updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
#### 制約
|
||||||
|
|
||||||
|
- `year + date` の一意制約は付けない
|
||||||
|
|
||||||
|
同日に午前・午後やエリア別で複数散布記録を分けられるようにする。
|
||||||
|
|
||||||
|
### 6.4 `SpreadingSessionItem`(新規)
|
||||||
|
|
||||||
|
散布日の圃場×肥料ごとの実績。
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| session | FK(SpreadingSession) | CASCADE | |
|
||||||
|
| field | FK(fields.Field) | PROTECT | |
|
||||||
|
| fertilizer | FK(Fertilizer) | PROTECT | |
|
||||||
|
| actual_bags | Decimal(10,4) | required | 実散布袋数 |
|
||||||
|
| planned_bags_snapshot | Decimal(10,4) | required | 表示時点の計画値 |
|
||||||
|
| delivered_bags_snapshot | Decimal(10,4) | required | 表示時点の運搬済み合計 |
|
||||||
|
| created_at / updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
#### 制約
|
||||||
|
|
||||||
|
- `unique_together = [['session', 'field', 'fertilizer']]`
|
||||||
|
|
||||||
|
#### 補足
|
||||||
|
|
||||||
|
- `fertilization_entry` FK は持たない
|
||||||
|
- 圃場+肥料単位の事実記録を優先する
|
||||||
|
|
||||||
|
### 6.5 `WorkRecord`(新規)
|
||||||
|
|
||||||
|
作業記録参照用の索引テーブル。
|
||||||
|
|
||||||
|
#### 目的
|
||||||
|
|
||||||
|
- 日付順に作業を一覧できるようにする
|
||||||
|
- 将来、播種・農薬散布・収穫なども同じ入口で見られるようにする
|
||||||
|
- ただし、詳細の本体は各業務テーブル側に持つ
|
||||||
|
|
||||||
|
| フィールド | 型 | 制約 | 説明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | int | PK | |
|
||||||
|
| work_date | DateField | required | 作業日 |
|
||||||
|
| work_type | CharField | required | `fertilizer_delivery` / `fertilizer_spreading` |
|
||||||
|
| title | varchar(200) | required | 一覧表示名 |
|
||||||
|
| year | int | required | 年度フィルタ補助 |
|
||||||
|
| auto_created | bool | default=True | 自動生成フラグ |
|
||||||
|
| delivery_trip | OneToOne FK(DeliveryTrip) | nullable | 運搬由来 |
|
||||||
|
| spreading_session | OneToOne FK(SpreadingSession) | nullable | 散布由来 |
|
||||||
|
| created_at / updated_at | datetime | auto | |
|
||||||
|
|
||||||
|
#### 方針
|
||||||
|
|
||||||
|
- `WorkRecord` 自体には圃場別明細を持たない
|
||||||
|
- 明細参照時は `DeliveryTrip` / `SpreadingSession` 側を開く
|
||||||
|
- 実体ではなく索引として使う
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 在庫連携
|
||||||
|
|
||||||
|
### 7.1 変更前
|
||||||
|
|
||||||
|
- 施肥計画保存時に `RESERVE`
|
||||||
|
- 施肥計画の `散布確定` で `USE`
|
||||||
|
|
||||||
|
### 7.2 変更後
|
||||||
|
|
||||||
|
- 施肥計画保存時に `RESERVE` を継続
|
||||||
|
- 散布実績保存時に `USE`
|
||||||
|
- `USE.occurred_on` は `SpreadingSession.date`
|
||||||
|
|
||||||
|
### 7.3 `StockTransaction` 追加推奨フィールド
|
||||||
|
|
||||||
|
- `spreading_item = FK(SpreadingSessionItem, null=True, blank=True, on_delete=SET_NULL)`
|
||||||
|
|
||||||
|
### 7.4 `USE` 作成ルール
|
||||||
|
|
||||||
|
- `SpreadingSessionItem` ごとに `USE` を1件作る
|
||||||
|
- `material` は `item.fertilizer.material`
|
||||||
|
- `quantity` は `actual_bags`
|
||||||
|
- `occurred_on` は `session.date`
|
||||||
|
- `note` は `散布実績「{session.name or session.date}」`
|
||||||
|
|
||||||
|
### 7.5 更新・削除
|
||||||
|
|
||||||
|
- 散布実績更新時は、その `session` に紐づく `USE` を全置換で作り直す
|
||||||
|
- 散布実績削除時は対応 `USE` を削除する
|
||||||
|
|
||||||
|
### 7.6 `RESERVE` との整合
|
||||||
|
|
||||||
|
- `RESERVE` は従来どおり計画値 `bags` ベースで維持する
|
||||||
|
- `USE` は散布実績 `actual_bags` ベースで発生する
|
||||||
|
- 計画値と実績値は併存する
|
||||||
|
|
||||||
|
つまり、
|
||||||
|
|
||||||
|
- 計画: `bags`
|
||||||
|
- 実績集計: `actual_bags`
|
||||||
|
- 引当: `RESERVE`
|
||||||
|
- 使用: `USE`
|
||||||
|
|
||||||
|
を分けて持つ。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 集計ルール
|
||||||
|
|
||||||
|
`SpreadingAllocation` を持たないため、残量は集計で求める。
|
||||||
|
|
||||||
|
### 8.1 計画値
|
||||||
|
|
||||||
|
`planned_total(field, fertilizer, year)`
|
||||||
|
|
||||||
|
- `FertilizationEntry` の合計
|
||||||
|
|
||||||
|
### 8.2 運搬済み量
|
||||||
|
|
||||||
|
`delivered_total(field, fertilizer, year)`
|
||||||
|
|
||||||
|
- `DeliveryTrip.date != null` の `DeliveryTripItem.bags` 合計
|
||||||
|
|
||||||
|
### 8.3 散布済み量
|
||||||
|
|
||||||
|
`spread_total(field, fertilizer, year)`
|
||||||
|
|
||||||
|
- `SpreadingSessionItem.actual_bags` の合計
|
||||||
|
|
||||||
|
### 8.4 `FertilizationEntry.actual_bags` 集計ルール
|
||||||
|
|
||||||
|
`actual_bags(field, fertilizer, year)`
|
||||||
|
|
||||||
|
- `SUM(SpreadingSessionItem.actual_bags)`
|
||||||
|
- 対象条件は `同一 year, field, fertilizer`
|
||||||
|
|
||||||
|
実装上は、散布実績保存・更新・削除時に、該当する `FertilizationEntry` を再計算して更新する。
|
||||||
|
|
||||||
|
### 8.5 表示用の残量
|
||||||
|
|
||||||
|
`remaining_bags = delivered_total - spread_total`
|
||||||
|
|
||||||
|
### 8.6 計画進捗用の残量
|
||||||
|
|
||||||
|
`remaining_plan_bags = planned_total - spread_total`
|
||||||
|
|
||||||
|
### 8.7 差異の扱い
|
||||||
|
|
||||||
|
運搬数量と散布数量がずれることは許容する。
|
||||||
|
|
||||||
|
- `remaining_bags < 0` の場合: `運搬実績不足`
|
||||||
|
- `remaining_plan_bags < 0` の場合: `計画超過`
|
||||||
|
|
||||||
|
まずは `圃場+肥料単位で差異が分かること` を優先する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API方針
|
||||||
|
|
||||||
|
### 9.1 施肥計画 API
|
||||||
|
|
||||||
|
#### UI上で廃止するもの
|
||||||
|
|
||||||
|
- `POST /api/fertilizer/plans/{id}/confirm_spreading/`
|
||||||
|
- `POST /api/fertilizer/plans/{id}/unconfirm/`
|
||||||
|
|
||||||
|
#### 追加する読み取り項目
|
||||||
|
|
||||||
|
- `spread_status`
|
||||||
|
- `planned_total_bags`
|
||||||
|
- `spread_total_bags`
|
||||||
|
- `remaining_total_bags`
|
||||||
|
- 各 `FertilizationEntry` の `actual_bags`
|
||||||
|
|
||||||
|
### 9.2 散布実績 API(新規)
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/fertilizer/spreading/?year={year}` | 年度別一覧 |
|
||||||
|
| POST | `/api/fertilizer/spreading/` | 新規作成 |
|
||||||
|
| GET | `/api/fertilizer/spreading/{id}/` | 詳細 |
|
||||||
|
| PUT | `/api/fertilizer/spreading/{id}/` | 更新 |
|
||||||
|
| DELETE | `/api/fertilizer/spreading/{id}/` | 削除 |
|
||||||
|
| GET | `/api/fertilizer/spreading/candidates/?year={year}` | 散布候補一覧 |
|
||||||
|
|
||||||
|
### 9.3 作業記録 API(新規)
|
||||||
|
|
||||||
|
| メソッド | URL | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/workrecords/?year={year}` | 一覧 |
|
||||||
|
| GET | `/api/workrecords/{id}/` | 詳細 |
|
||||||
|
|
||||||
|
詳細では元レコードへのリンク情報を返すだけでよい。
|
||||||
|
|
||||||
|
### 9.4 候補一覧レスポンス例
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"field": 5,
|
||||||
|
"field_name": "田中上",
|
||||||
|
"fertilizer": 1,
|
||||||
|
"fertilizer_name": "電気炉さい",
|
||||||
|
"planned_bags": "4.0000",
|
||||||
|
"delivered_bags": "4.0000",
|
||||||
|
"spread_bags": "1.5000",
|
||||||
|
"remaining_bags": "2.5000",
|
||||||
|
"remaining_plan_bags": "2.5000",
|
||||||
|
"delivery_gap": "0.0000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 保存時の基本バリデーション
|
||||||
|
|
||||||
|
- `actual_bags > 0` を必須
|
||||||
|
- 同一 `session` 内で `field + fertilizer` の重複禁止
|
||||||
|
- `remaining_plan_bags` を大きく超える入力はエラー
|
||||||
|
- `remaining_bags` を超える入力は警告を出した上で保存は許容してよい
|
||||||
|
|
||||||
|
### 9.6 `actual_bags` 同期APIルール
|
||||||
|
|
||||||
|
- 散布実績保存後、対象となる `FertilizationEntry.actual_bags` を即時再集計する
|
||||||
|
- `SUM(...) = 0` の場合は `actual_bags = null` としてよい
|
||||||
|
- 一覧・編集画面では、再計算後の値を返す
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. フロントエンド変更
|
||||||
|
|
||||||
|
### 10.1 施肥計画一覧 `/fertilizer`
|
||||||
|
|
||||||
|
- `散布確定` ボタンを削除
|
||||||
|
- `ConfirmSpreadingModal.tsx` は削除対象
|
||||||
|
- カードに進捗状態を表示
|
||||||
|
- 計画値と実績値を並べて表示できるようにする
|
||||||
|
|
||||||
|
表示例:
|
||||||
|
|
||||||
|
- `未散布`
|
||||||
|
- `一部散布 3.5 / 8.0袋`
|
||||||
|
- `散布完了`
|
||||||
|
- `計画超過`
|
||||||
|
- `計画 4.0 → 実績 3.5`
|
||||||
|
|
||||||
|
### 10.2 散布実績画面(新規)
|
||||||
|
|
||||||
|
- `/fertilizer/spreading`
|
||||||
|
|
||||||
|
#### 画面要件
|
||||||
|
|
||||||
|
- 散布日入力
|
||||||
|
- 年度選択
|
||||||
|
- 運搬済み・未散布候補一覧
|
||||||
|
- 圃場単位選択
|
||||||
|
- 実績袋数編集
|
||||||
|
- 差異のインライン警告
|
||||||
|
|
||||||
|
### 10.4 施肥計画編集画面
|
||||||
|
|
||||||
|
- `bags` を計画値として表示
|
||||||
|
- `actual_bags` を実績値として参照表示
|
||||||
|
- 編集時にも `計画 / 実績` の差が分かるようにする
|
||||||
|
|
||||||
|
`actual_bags` は散布実績からの集計値なので、編集画面で直接入力はしない。
|
||||||
|
|
||||||
|
### 10.3 作業記録画面(新規または将来画面の土台)
|
||||||
|
|
||||||
|
最低限、以下を一覧できること。
|
||||||
|
|
||||||
|
- 日付
|
||||||
|
- 作業種別
|
||||||
|
- タイトル
|
||||||
|
- 元データへの遷移
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 将来の資料生成に向けた要件
|
||||||
|
|
||||||
|
今回、固定形式のPDFは実装しない。
|
||||||
|
|
||||||
|
ただし、将来どの相手先様式にも対応できるよう、散布実績から少なくとも以下を引き出せる必要がある。
|
||||||
|
|
||||||
|
- 散布日
|
||||||
|
- 年度
|
||||||
|
- 圃場
|
||||||
|
- 肥料
|
||||||
|
- 散布袋数
|
||||||
|
- 備考
|
||||||
|
|
||||||
|
また、資料生成時に計画値と実績値を比較できるよう、少なくとも以下の対比を維持する。
|
||||||
|
|
||||||
|
- `bags`(計画)
|
||||||
|
- `actual_bags`(実績)
|
||||||
|
|
||||||
|
つまり今回の要件は、`PDFを作ること` ではなく、`後で資料化できる元データを崩さず持つこと` である。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 前年度コピーへの影響
|
||||||
|
|
||||||
|
将来 `copy_from_previous_year` で前年度の `FertilizationEntry` をコピーする際は、以下のルールを適用する。
|
||||||
|
|
||||||
|
- `actual_bags` がある場合: `actual_bags` を新年度の `bags` 初期値として使う
|
||||||
|
- `actual_bags` が `null` の場合: 従来どおり `bags` をコピーする
|
||||||
|
|
||||||
|
この方針により、前年度に実際に散布した量を次年度計画の初期値として再利用できる。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. WorkRecord 自動生成ルール
|
||||||
|
|
||||||
|
### 12.1 運搬
|
||||||
|
|
||||||
|
- `DeliveryTrip.date` 保存時に upsert
|
||||||
|
- `title = 肥料運搬: {delivery_plan.name} {n}回目`
|
||||||
|
- 日付削除時は対応 `WorkRecord` を削除
|
||||||
|
|
||||||
|
### 12.2 散布
|
||||||
|
|
||||||
|
- `SpreadingSession` 保存時に upsert
|
||||||
|
- `title = 肥料散布: {session.name or session.date}`
|
||||||
|
- 削除時は対応 `WorkRecord` を削除
|
||||||
|
|
||||||
|
### 12.3 実装方針
|
||||||
|
|
||||||
|
自動生成は view に直書きせず、サービス層で idempotent に実装する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 移行方針
|
||||||
|
|
||||||
|
### 13.1 既存の `is_confirmed`
|
||||||
|
|
||||||
|
- 既存カラムは残す
|
||||||
|
- 新UIでは参照しない
|
||||||
|
- 旧方式の確定データは `旧散布確定データ` として扱う
|
||||||
|
|
||||||
|
### 13.2 既存 `confirm_spreading` API
|
||||||
|
|
||||||
|
- まずフロントから呼ばない状態にする
|
||||||
|
- しばらく互換維持
|
||||||
|
- 新散布実績画面へ完全移行後に削除を検討する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 実装ステップ
|
||||||
|
|
||||||
|
1. `SpreadingSession` / `SpreadingSessionItem` を追加
|
||||||
|
2. `apps/workrecords/` を追加
|
||||||
|
3. `FertilizationEntry.actual_bags` を追加
|
||||||
|
4. `StockTransaction.spreading_item` を追加
|
||||||
|
5. 散布実績 API を追加
|
||||||
|
6. 作業記録 API を追加
|
||||||
|
7. 散布候補集計 API を追加
|
||||||
|
8. 散布保存時の `actual_bags` 再集計を追加
|
||||||
|
9. 施肥計画一覧に進捗表示を追加
|
||||||
|
10. 施肥計画一覧から `散布確定` ボタンを外す
|
||||||
|
11. `ConfirmSpreadingModal.tsx` を撤去
|
||||||
|
12. 散布実績画面を追加
|
||||||
|
13. WorkRecord 自動生成を追加
|
||||||
|
14. 散布保存時の在庫 `USE` 連携を追加
|
||||||
|
15. `copy_from_previous_year` の `actual_bags → bags` 反映を追加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 影響ファイル(想定)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `backend/apps/fertilizer/models.py`
|
||||||
|
- `backend/apps/fertilizer/serializers.py`
|
||||||
|
- `backend/apps/fertilizer/views.py`
|
||||||
|
- `backend/apps/fertilizer/urls.py`
|
||||||
|
- `backend/apps/fertilizer/admin.py`
|
||||||
|
- `backend/apps/fertilizer/migrations/`
|
||||||
|
- `backend/apps/fertilizer/services.py` または同等の集計ロジック配置先
|
||||||
|
- `backend/apps/materials/models.py`
|
||||||
|
- `backend/apps/materials/stock_service.py`
|
||||||
|
- `backend/apps/materials/migrations/`
|
||||||
|
- `backend/apps/workrecords/`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `frontend/src/app/fertilizer/page.tsx`
|
||||||
|
- `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx`
|
||||||
|
- `frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx`
|
||||||
|
- `frontend/src/app/fertilizer/spreading/`
|
||||||
|
- `frontend/src/app/workrecords/`
|
||||||
|
- `frontend/src/types/index.ts`
|
||||||
|
|
||||||
|
### ドキュメント
|
||||||
|
|
||||||
|
- `document/13_マスタードキュメント_施肥計画編.md`
|
||||||
|
- `document/14_マスタードキュメント_分配計画編.md`
|
||||||
|
- `CLAUDE.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 受け入れ条件
|
||||||
|
|
||||||
|
- 施肥計画一覧に `散布確定` ボタンが表示されない
|
||||||
|
- 日付単位の散布実績を登録できる
|
||||||
|
- 散布対象は全部または一部の圃場を選べる
|
||||||
|
- 運搬回の順番に依存せず、運搬済みデータを元に散布できる
|
||||||
|
- 実際の散布袋数を入力できる
|
||||||
|
- 散布保存時に在庫 `USE` が作成される
|
||||||
|
- 散布保存・更新・削除時に `FertilizationEntry.actual_bags` が再集計される
|
||||||
|
- 作業記録一覧から運搬と散布を参照できる
|
||||||
|
- 施肥計画一覧で `未散布 / 一部散布 / 完了 / 計画超過` が分かる
|
||||||
|
- 施肥計画一覧・編集画面で `計画値 / 実績値` の両方が分かる
|
||||||
|
- 前年度コピー時に `actual_bags` があればそれを次年度 `bags` 初期値として使える
|
||||||
|
- 将来、散布実績データから資料化に必要な情報を取り出せる
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 将来拡張
|
||||||
|
|
||||||
|
今回見送るが、将来必要になれば以下を追加できる。
|
||||||
|
|
||||||
|
- `SpreadingAllocation` による便単位追跡
|
||||||
|
- 相手先別PDFテンプレート群
|
||||||
|
- 残肥返却や再入庫
|
||||||
|
- 栽培管理全体を包含した作業記録詳細
|
||||||
Reference in New Issue
Block a user