From 497bc87c24da441a7a9bfbd1480c4cc38395d6f8 Mon Sep 17 00:00:00 2001 From: Akira Date: Sat, 14 Mar 2026 15:42:47 +0900 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E5=BA=AB=E7=AE=A1=E7=90=86=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=20Phase=201=20=E5=AE=9F=E8=A3=85=EF=BC=88apps/materia?= =?UTF-8?q?ls=20+=20=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88=E3=82=A8=E3=83=B3?= =?UTF-8?q?=E3=83=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - apps/materials 新規作成(Material, FertilizerProfile, PesticideProfile, StockTransaction) - 資材マスタ CRUD API(/api/materials/materials/) - 入出庫履歴 API(/api/materials/stock-transactions/) - 在庫集計 API(/api/materials/stock-summary/) - 既存 Fertilizer に material OneToOneField 追加(0005マイグレーション、データ移行込み) Frontend: - /materials: 在庫一覧画面(タブフィルタ、履歴展開、入出庫モーダル) - /materials/masters: 資材マスタ管理(肥料/農薬/その他タブ、インライン編集) - Navbar に「在庫管理」メニュー追加 - Material/StockTransaction/StockSummary 型定義追加 Co-Authored-By: Claude Opus 4.6 --- .../migrations/0005_fertilizer_material.py | 56 ++ backend/apps/fertilizer/models.py | 8 + backend/apps/materials/__init__.py | 1 + backend/apps/materials/admin.py | 28 + backend/apps/materials/apps.py | 8 + .../apps/materials/migrations/0001_initial.py | 87 +++ backend/apps/materials/migrations/__init__.py | 1 + backend/apps/materials/models.py | 210 +++++++ backend/apps/materials/serializers.py | 212 +++++++ backend/apps/materials/urls.py | 17 + backend/apps/materials/views.py | 134 ++++ backend/keinasystem/settings.py | 1 + backend/keinasystem/urls.py | 1 + .../materials/_components/MaterialForm.tsx | 341 ++++++++++ .../materials/_components/StockOverview.tsx | 157 +++++ .../_components/StockTransactionForm.tsx | 211 +++++++ frontend/src/app/materials/masters/page.tsx | 590 ++++++++++++++++++ frontend/src/app/materials/page.tsx | 208 ++++++ frontend/src/components/Navbar.tsx | 13 +- frontend/src/types/index.ts | 61 ++ 20 files changed, 2344 insertions(+), 1 deletion(-) create mode 100644 backend/apps/fertilizer/migrations/0005_fertilizer_material.py create mode 100644 backend/apps/materials/__init__.py create mode 100644 backend/apps/materials/admin.py create mode 100644 backend/apps/materials/apps.py create mode 100644 backend/apps/materials/migrations/0001_initial.py create mode 100644 backend/apps/materials/migrations/__init__.py create mode 100644 backend/apps/materials/models.py create mode 100644 backend/apps/materials/serializers.py create mode 100644 backend/apps/materials/urls.py create mode 100644 backend/apps/materials/views.py create mode 100644 frontend/src/app/materials/_components/MaterialForm.tsx create mode 100644 frontend/src/app/materials/_components/StockOverview.tsx create mode 100644 frontend/src/app/materials/_components/StockTransactionForm.tsx create mode 100644 frontend/src/app/materials/masters/page.tsx create mode 100644 frontend/src/app/materials/page.tsx diff --git a/backend/apps/fertilizer/migrations/0005_fertilizer_material.py b/backend/apps/fertilizer/migrations/0005_fertilizer_material.py new file mode 100644 index 0000000..15e6984 --- /dev/null +++ b/backend/apps/fertilizer/migrations/0005_fertilizer_material.py @@ -0,0 +1,56 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def create_materials_for_existing_fertilizers(apps, schema_editor): + Fertilizer = apps.get_model('fertilizer', 'Fertilizer') + Material = apps.get_model('materials', 'Material') + FertilizerProfile = apps.get_model('materials', 'FertilizerProfile') + + for fertilizer in Fertilizer.objects.all(): + material = Material.objects.create( + name=fertilizer.name, + material_type='fertilizer', + maker=fertilizer.maker or '', + stock_unit='bag', + is_active=True, + notes=fertilizer.notes or '', + ) + FertilizerProfile.objects.create( + material=material, + capacity_kg=fertilizer.capacity_kg, + nitrogen_pct=fertilizer.nitrogen_pct, + phosphorus_pct=fertilizer.phosphorus_pct, + potassium_pct=fertilizer.potassium_pct, + ) + fertilizer.material = material + fertilizer.save(update_fields=['material']) + + +def reverse_migration(apps, schema_editor): + Fertilizer = apps.get_model('fertilizer', 'Fertilizer') + Fertilizer.objects.all().update(material=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('fertilizer', '0004_fertilizationplan_calc_settings'), + ('materials', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='fertilizer', + name='material', + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='legacy_fertilizer', + to='materials.material', + verbose_name='資材マスタ', + ), + ), + migrations.RunPython(create_materials_for_existing_fertilizers, reverse_migration), + ] diff --git a/backend/apps/fertilizer/models.py b/backend/apps/fertilizer/models.py index e758521..1f9ad58 100644 --- a/backend/apps/fertilizer/models.py +++ b/backend/apps/fertilizer/models.py @@ -17,6 +17,14 @@ class Fertilizer(models.Model): max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)' ) notes = models.TextField(blank=True, null=True, verbose_name='備考') + material = models.OneToOneField( + 'materials.Material', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='legacy_fertilizer', + verbose_name='資材マスタ', + ) class Meta: verbose_name = '肥料マスタ' diff --git a/backend/apps/materials/__init__.py b/backend/apps/materials/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/materials/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/materials/admin.py b/backend/apps/materials/admin.py new file mode 100644 index 0000000..8babbfb --- /dev/null +++ b/backend/apps/materials/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from .models import FertilizerProfile, Material, PesticideProfile, StockTransaction + + +class FertilizerProfileInline(admin.StackedInline): + model = FertilizerProfile + extra = 0 + + +class PesticideProfileInline(admin.StackedInline): + model = PesticideProfile + extra = 0 + + +@admin.register(Material) +class MaterialAdmin(admin.ModelAdmin): + list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active'] + list_filter = ['material_type', 'is_active'] + search_fields = ['name', 'maker'] + inlines = [FertilizerProfileInline, PesticideProfileInline] + + +@admin.register(StockTransaction) +class StockTransactionAdmin(admin.ModelAdmin): + list_display = ['material', 'transaction_type', 'quantity', 'occurred_on'] + list_filter = ['transaction_type', 'occurred_on'] + search_fields = ['material__name'] diff --git a/backend/apps/materials/apps.py b/backend/apps/materials/apps.py new file mode 100644 index 0000000..56ff272 --- /dev/null +++ b/backend/apps/materials/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class MaterialsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.materials' + verbose_name = '資材管理' + diff --git a/backend/apps/materials/migrations/0001_initial.py b/backend/apps/materials/migrations/0001_initial.py new file mode 100644 index 0000000..a75bf6e --- /dev/null +++ b/backend/apps/materials/migrations/0001_initial.py @@ -0,0 +1,87 @@ +import decimal + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Material', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='資材名')), + ('material_type', models.CharField(choices=[('fertilizer', '肥料'), ('pesticide', '農薬'), ('seedling', '種苗'), ('other', 'その他')], max_length=20, verbose_name='資材種別')), + ('maker', models.CharField(blank=True, default='', max_length=100, verbose_name='メーカー')), + ('stock_unit', models.CharField(choices=[('bag', '袋'), ('bottle', '本'), ('kg', 'kg'), ('liter', 'L'), ('piece', '個')], default='bag', max_length=20, verbose_name='在庫単位')), + ('is_active', models.BooleanField(default=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)), + ], + options={ + 'verbose_name': '資材', + 'verbose_name_plural': '資材', + 'ordering': ['material_type', 'name'], + }, + ), + migrations.CreateModel( + name='FertilizerProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')), + ('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素(%)')), + ('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸(%)')), + ('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ(%)')), + ('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fertilizer_profile', to='materials.material')), + ], + options={ + 'verbose_name': '肥料詳細', + 'verbose_name_plural': '肥料詳細', + }, + ), + migrations.CreateModel( + name='PesticideProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('registration_no', models.CharField(blank=True, default='', max_length=100, verbose_name='農薬登録番号')), + ('formulation', models.CharField(blank=True, default='', max_length=100, verbose_name='剤型')), + ('usage_unit', models.CharField(blank=True, default='', max_length=50, verbose_name='使用単位')), + ('dilution_ratio', models.CharField(blank=True, default='', max_length=100, verbose_name='希釈倍率')), + ('active_ingredient', models.CharField(blank=True, default='', max_length=200, verbose_name='有効成分')), + ('category', models.CharField(blank=True, default='', max_length=100, verbose_name='分類')), + ('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pesticide_profile', to='materials.material')), + ], + options={ + 'verbose_name': '農薬詳細', + 'verbose_name_plural': '農薬詳細', + }, + ), + migrations.CreateModel( + name='StockTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_type', models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(decimal.Decimal('0.001'))], verbose_name='数量')), + ('occurred_on', models.DateField(verbose_name='発生日')), + ('note', models.TextField(blank=True, default='', verbose_name='備考')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_transactions', to='materials.material', verbose_name='資材')), + ], + options={ + 'verbose_name': '入出庫履歴', + 'verbose_name_plural': '入出庫履歴', + 'ordering': ['-occurred_on', '-created_at', '-id'], + }, + ), + migrations.AddConstraint( + model_name='material', + constraint=models.UniqueConstraint(fields=('material_type', 'name'), name='uniq_material_type_name'), + ), + ] diff --git a/backend/apps/materials/migrations/__init__.py b/backend/apps/materials/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/materials/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/materials/models.py b/backend/apps/materials/models.py new file mode 100644 index 0000000..0662f4f --- /dev/null +++ b/backend/apps/materials/models.py @@ -0,0 +1,210 @@ +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models + + +class Material(models.Model): + """共通資材マスタ""" + + class MaterialType(models.TextChoices): + FERTILIZER = 'fertilizer', '肥料' + PESTICIDE = 'pesticide', '農薬' + SEEDLING = 'seedling', '種苗' + OTHER = 'other', 'その他' + + class StockUnit(models.TextChoices): + BAG = 'bag', '袋' + BOTTLE = 'bottle', '本' + KG = 'kg', 'kg' + LITER = 'liter', 'L' + PIECE = 'piece', '個' + + name = models.CharField(max_length=100, verbose_name='資材名') + material_type = models.CharField( + max_length=20, + choices=MaterialType.choices, + verbose_name='資材種別', + ) + maker = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='メーカー', + ) + stock_unit = models.CharField( + max_length=20, + choices=StockUnit.choices, + default=StockUnit.BAG, + verbose_name='在庫単位', + ) + is_active = models.BooleanField(default=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: + ordering = ['material_type', 'name'] + constraints = [ + models.UniqueConstraint( + fields=['material_type', 'name'], + name='uniq_material_type_name', + ), + ] + verbose_name = '資材' + verbose_name_plural = '資材' + + def __str__(self): + return f'{self.get_material_type_display()}: {self.name}' + + +class FertilizerProfile(models.Model): + """肥料専用属性""" + + material = models.OneToOneField( + Material, + on_delete=models.CASCADE, + related_name='fertilizer_profile', + ) + capacity_kg = models.DecimalField( + max_digits=8, + decimal_places=3, + blank=True, + null=True, + verbose_name='1袋重量(kg)', + ) + nitrogen_pct = models.DecimalField( + max_digits=5, + decimal_places=2, + blank=True, + null=True, + verbose_name='窒素(%)', + ) + phosphorus_pct = models.DecimalField( + max_digits=5, + decimal_places=2, + blank=True, + null=True, + verbose_name='リン酸(%)', + ) + potassium_pct = models.DecimalField( + max_digits=5, + decimal_places=2, + blank=True, + null=True, + verbose_name='カリ(%)', + ) + + class Meta: + verbose_name = '肥料詳細' + verbose_name_plural = '肥料詳細' + + def __str__(self): + return f'肥料詳細: {self.material.name}' + + +class PesticideProfile(models.Model): + """農薬専用属性""" + + material = models.OneToOneField( + Material, + on_delete=models.CASCADE, + related_name='pesticide_profile', + ) + registration_no = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='農薬登録番号', + ) + formulation = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='剤型', + ) + usage_unit = models.CharField( + max_length=50, + blank=True, + default='', + verbose_name='使用単位', + ) + dilution_ratio = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='希釈倍率', + ) + active_ingredient = models.CharField( + max_length=200, + blank=True, + default='', + verbose_name='有効成分', + ) + category = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='分類', + ) + + class Meta: + verbose_name = '農薬詳細' + verbose_name_plural = '農薬詳細' + + def __str__(self): + return f'農薬詳細: {self.material.name}' + + +class StockTransaction(models.Model): + """入出庫履歴""" + + class TransactionType(models.TextChoices): + PURCHASE = 'purchase', '入庫' + USE = 'use', '使用' + ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増' + ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減' + DISCARD = 'discard', '廃棄' + + INCREASE_TYPES = { + TransactionType.PURCHASE, + TransactionType.ADJUSTMENT_PLUS, + } + DECREASE_TYPES = { + TransactionType.USE, + TransactionType.ADJUSTMENT_MINUS, + TransactionType.DISCARD, + } + + material = models.ForeignKey( + Material, + on_delete=models.PROTECT, + related_name='stock_transactions', + verbose_name='資材', + ) + transaction_type = models.CharField( + max_length=30, + choices=TransactionType.choices, + verbose_name='取引種別', + ) + quantity = models.DecimalField( + max_digits=10, + decimal_places=3, + validators=[MinValueValidator(Decimal('0.001'))], + verbose_name='数量', + ) + occurred_on = models.DateField(verbose_name='発生日') + note = models.TextField(blank=True, default='', verbose_name='備考') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-occurred_on', '-created_at', '-id'] + verbose_name = '入出庫履歴' + verbose_name_plural = '入出庫履歴' + + def __str__(self): + return ( + f'{self.material.name} ' + f'{self.get_transaction_type_display()} ' + f'{self.quantity}' + ) diff --git a/backend/apps/materials/serializers.py b/backend/apps/materials/serializers.py new file mode 100644 index 0000000..d070017 --- /dev/null +++ b/backend/apps/materials/serializers.py @@ -0,0 +1,212 @@ +from decimal import Decimal + +from django.db import transaction +from rest_framework import serializers + +from .models import ( + FertilizerProfile, + Material, + PesticideProfile, + StockTransaction, +) + + +class FertilizerProfileSerializer(serializers.ModelSerializer): + class Meta: + model = FertilizerProfile + fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct'] + + +class PesticideProfileSerializer(serializers.ModelSerializer): + class Meta: + model = PesticideProfile + fields = [ + 'registration_no', + 'formulation', + 'usage_unit', + 'dilution_ratio', + 'active_ingredient', + 'category', + ] + + +class MaterialReadSerializer(serializers.ModelSerializer): + material_type_display = serializers.CharField( + source='get_material_type_display', + read_only=True, + ) + stock_unit_display = serializers.CharField( + source='get_stock_unit_display', + read_only=True, + ) + fertilizer_profile = FertilizerProfileSerializer(read_only=True) + pesticide_profile = PesticideProfileSerializer(read_only=True) + current_stock = serializers.SerializerMethodField() + + class Meta: + model = Material + fields = [ + 'id', + 'name', + 'material_type', + 'material_type_display', + 'maker', + 'stock_unit', + 'stock_unit_display', + 'is_active', + 'notes', + 'fertilizer_profile', + 'pesticide_profile', + 'current_stock', + 'created_at', + 'updated_at', + ] + + def get_current_stock(self, obj): + transactions = list(obj.stock_transactions.all()) + increase = sum( + transaction.quantity + for transaction in transactions + if transaction.transaction_type in StockTransaction.INCREASE_TYPES + ) + decrease = sum( + transaction.quantity + for transaction in transactions + if transaction.transaction_type in StockTransaction.DECREASE_TYPES + ) + return increase - decrease + + +class MaterialWriteSerializer(serializers.ModelSerializer): + fertilizer_profile = FertilizerProfileSerializer(required=False, allow_null=True) + pesticide_profile = PesticideProfileSerializer(required=False, allow_null=True) + + class Meta: + model = Material + fields = [ + 'id', + 'name', + 'material_type', + 'maker', + 'stock_unit', + 'is_active', + 'notes', + 'fertilizer_profile', + 'pesticide_profile', + ] + + def validate(self, attrs): + material_type = attrs.get('material_type') + if self.instance is not None and material_type is None: + material_type = self.instance.material_type + + fertilizer_profile = attrs.get('fertilizer_profile') + pesticide_profile = attrs.get('pesticide_profile') + + if material_type == Material.MaterialType.FERTILIZER and pesticide_profile: + raise serializers.ValidationError( + {'pesticide_profile': '肥料には農薬詳細を設定できません。'} + ) + if material_type == Material.MaterialType.PESTICIDE and fertilizer_profile: + raise serializers.ValidationError( + {'fertilizer_profile': '農薬には肥料詳細を設定できません。'} + ) + if ( + material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER} + and (fertilizer_profile or pesticide_profile) + ): + raise serializers.ValidationError( + '種苗・その他には詳細プロファイルを設定できません。' + ) + return attrs + + @transaction.atomic + def create(self, validated_data): + fertilizer_profile_data = validated_data.pop('fertilizer_profile', None) + pesticide_profile_data = validated_data.pop('pesticide_profile', None) + + material = Material.objects.create(**validated_data) + self._save_profiles(material, fertilizer_profile_data, pesticide_profile_data) + return material + + @transaction.atomic + def update(self, instance, validated_data): + fertilizer_profile_data = validated_data.pop('fertilizer_profile', None) + pesticide_profile_data = validated_data.pop('pesticide_profile', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + self._save_profiles(instance, fertilizer_profile_data, pesticide_profile_data) + return instance + + def to_representation(self, instance): + return MaterialReadSerializer(instance, context=self.context).data + + def _save_profiles(self, material, fertilizer_profile_data, pesticide_profile_data): + if material.material_type == Material.MaterialType.FERTILIZER: + if fertilizer_profile_data is not None: + profile, _ = FertilizerProfile.objects.get_or_create(material=material) + for attr, value in fertilizer_profile_data.items(): + setattr(profile, attr, value) + profile.save() + PesticideProfile.objects.filter(material=material).delete() + return + + if material.material_type == Material.MaterialType.PESTICIDE: + if pesticide_profile_data is not None: + profile, _ = PesticideProfile.objects.get_or_create(material=material) + for attr, value in pesticide_profile_data.items(): + setattr(profile, attr, value) + profile.save() + FertilizerProfile.objects.filter(material=material).delete() + return + + FertilizerProfile.objects.filter(material=material).delete() + PesticideProfile.objects.filter(material=material).delete() + + +class StockTransactionSerializer(serializers.ModelSerializer): + material_name = serializers.CharField(source='material.name', read_only=True) + material_type = serializers.CharField(source='material.material_type', read_only=True) + stock_unit = serializers.CharField(source='material.stock_unit', read_only=True) + stock_unit_display = serializers.CharField( + source='material.get_stock_unit_display', + read_only=True, + ) + transaction_type_display = serializers.CharField( + source='get_transaction_type_display', + read_only=True, + ) + + class Meta: + model = StockTransaction + fields = [ + 'id', + 'material', + 'material_name', + 'material_type', + 'transaction_type', + 'transaction_type_display', + 'quantity', + 'stock_unit', + 'stock_unit_display', + 'occurred_on', + 'note', + 'created_at', + ] + read_only_fields = ['created_at'] + + +class StockSummarySerializer(serializers.Serializer): + material_id = serializers.IntegerField() + name = serializers.CharField() + material_type = serializers.CharField() + material_type_display = serializers.CharField() + maker = serializers.CharField() + stock_unit = serializers.CharField() + stock_unit_display = serializers.CharField() + is_active = serializers.BooleanField() + current_stock = serializers.DecimalField(max_digits=10, decimal_places=3) + last_transaction_date = serializers.DateField(allow_null=True) diff --git a/backend/apps/materials/urls.py b/backend/apps/materials/urls.py new file mode 100644 index 0000000..dc15491 --- /dev/null +++ b/backend/apps/materials/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register(r'materials', views.MaterialViewSet, basename='material') +router.register( + r'stock-transactions', + views.StockTransactionViewSet, + basename='stock-transaction', +) + +urlpatterns = [ + path('', include(router.urls)), + path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'), +] diff --git a/backend/apps/materials/views.py b/backend/apps/materials/views.py new file mode 100644 index 0000000..8ca0831 --- /dev/null +++ b/backend/apps/materials/views.py @@ -0,0 +1,134 @@ +from decimal import Decimal + +from rest_framework import generics, status, viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import Material, StockTransaction +from .serializers import ( + MaterialReadSerializer, + MaterialWriteSerializer, + StockSummarySerializer, + StockTransactionSerializer, +) + + +class MaterialViewSet(viewsets.ModelViewSet): + """資材マスタ CRUD""" + + permission_classes = [IsAuthenticated] + + def get_queryset(self): + queryset = Material.objects.select_related( + 'fertilizer_profile', + 'pesticide_profile', + ).prefetch_related('stock_transactions') + + material_type = self.request.query_params.get('material_type') + if material_type: + queryset = queryset.filter(material_type=material_type) + + active = self.request.query_params.get('active') + if active is not None: + queryset = queryset.filter(is_active=active.lower() == 'true') + + return queryset + + def get_serializer_class(self): + if self.action in ['create', 'update', 'partial_update']: + return MaterialWriteSerializer + return MaterialReadSerializer + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.stock_transactions.exists(): + return Response( + {'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'}, + status=status.HTTP_400_BAD_REQUEST, + ) + return super().destroy(request, *args, **kwargs) + + +class StockTransactionViewSet(viewsets.ModelViewSet): + """入出庫履歴 CRUD""" + + serializer_class = StockTransactionSerializer + permission_classes = [IsAuthenticated] + http_method_names = ['get', 'post', 'delete', 'head', 'options'] + + def get_queryset(self): + queryset = StockTransaction.objects.select_related('material') + + material_id = self.request.query_params.get('material_id') + if material_id: + queryset = queryset.filter(material_id=material_id) + + material_type = self.request.query_params.get('material_type') + if material_type: + queryset = queryset.filter(material__material_type=material_type) + + date_from = self.request.query_params.get('date_from') + if date_from: + queryset = queryset.filter(occurred_on__gte=date_from) + + date_to = self.request.query_params.get('date_to') + if date_to: + queryset = queryset.filter(occurred_on__lte=date_to) + + return queryset + + +class StockSummaryView(generics.ListAPIView): + """在庫集計一覧""" + + serializer_class = StockSummarySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Material.objects.none() + + def list(self, request, *args, **kwargs): + queryset = Material.objects.prefetch_related('stock_transactions').order_by( + 'material_type', + 'name', + ) + + material_type = request.query_params.get('material_type') + if material_type: + queryset = queryset.filter(material_type=material_type) + + active = request.query_params.get('active') + if active is not None: + queryset = queryset.filter(is_active=active.lower() == 'true') + + results = [] + for material in queryset: + transactions = list(material.stock_transactions.all()) + increase = sum( + txn.quantity + for txn in transactions + if txn.transaction_type in StockTransaction.INCREASE_TYPES + ) + decrease = sum( + txn.quantity + for txn in transactions + if txn.transaction_type in StockTransaction.DECREASE_TYPES + ) + last_date = max((txn.occurred_on for txn in transactions), default=None) + results.append( + { + 'material_id': material.id, + 'name': material.name, + 'material_type': material.material_type, + 'material_type_display': material.get_material_type_display(), + 'maker': material.maker, + 'stock_unit': material.stock_unit, + 'stock_unit_display': material.get_stock_unit_display(), + 'is_active': material.is_active, + 'current_stock': increase - decrease if transactions else Decimal('0'), + 'last_transaction_date': last_date, + } + ) + + serializer = self.get_serializer(results, many=True) + return Response(serializer.data) diff --git a/backend/keinasystem/settings.py b/backend/keinasystem/settings.py index aff8d1f..8a8dec9 100644 --- a/backend/keinasystem/settings.py +++ b/backend/keinasystem/settings.py @@ -44,6 +44,7 @@ INSTALLED_APPS = [ 'apps.mail', 'apps.weather', 'apps.fertilizer', + 'apps.materials', ] MIDDLEWARE = [ diff --git a/backend/keinasystem/urls.py b/backend/keinasystem/urls.py index c52caaf..15ec930 100644 --- a/backend/keinasystem/urls.py +++ b/backend/keinasystem/urls.py @@ -58,4 +58,5 @@ urlpatterns = [ path('api/mail/', include('apps.mail.urls')), path('api/weather/', include('apps.weather.urls')), path('api/fertilizer/', include('apps.fertilizer.urls')), + path('api/materials/', include('apps.materials.urls')), ] diff --git a/frontend/src/app/materials/_components/MaterialForm.tsx b/frontend/src/app/materials/_components/MaterialForm.tsx new file mode 100644 index 0000000..1398010 --- /dev/null +++ b/frontend/src/app/materials/_components/MaterialForm.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { Check, X } from 'lucide-react'; + +import { Material } from '@/types'; + +export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc'; + +export interface MaterialFormState { + name: string; + material_type: Material['material_type']; + maker: string; + stock_unit: Material['stock_unit']; + is_active: boolean; + notes: string; + fertilizer_profile: { + capacity_kg: string; + nitrogen_pct: string; + phosphorus_pct: string; + potassium_pct: string; + }; + pesticide_profile: { + registration_no: string; + formulation: string; + usage_unit: string; + dilution_ratio: string; + active_ingredient: string; + category: string; + }; +} + +interface MaterialFormProps { + tab: MaterialTab; + form: MaterialFormState; + saving: boolean; + onBaseFieldChange: ( + field: keyof Omit, + value: string | boolean + ) => void; + onFertilizerFieldChange: ( + field: keyof MaterialFormState['fertilizer_profile'], + value: string + ) => void; + onPesticideFieldChange: ( + field: keyof MaterialFormState['pesticide_profile'], + value: string + ) => void; + onSave: () => void; + onCancel: () => void; +} + +const inputClassName = + 'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500'; + +export default function MaterialForm({ + tab, + form, + saving, + onBaseFieldChange, + onFertilizerFieldChange, + onPesticideFieldChange, + onSave, + onCancel, +}: MaterialFormProps) { + if (tab === 'fertilizer') { + return ( + + + onBaseFieldChange('name', e.target.value)} + placeholder="資材名" + autoFocus + /> + + + onBaseFieldChange('maker', e.target.value)} + placeholder="メーカー" + /> + + + onFertilizerFieldChange('capacity_kg', e.target.value)} + placeholder="kg" + /> + + + onFertilizerFieldChange('nitrogen_pct', e.target.value)} + placeholder="%" + /> + + + onFertilizerFieldChange('phosphorus_pct', e.target.value)} + placeholder="%" + /> + + + onFertilizerFieldChange('potassium_pct', e.target.value)} + placeholder="%" + /> + + + onBaseFieldChange('stock_unit', value)} + /> + + + onBaseFieldChange('notes', e.target.value)} + placeholder="備考" + /> + + + onBaseFieldChange('is_active', e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500" + /> + + + + + + ); + } + + if (tab === 'pesticide') { + return ( + + + onBaseFieldChange('name', e.target.value)} + placeholder="資材名" + autoFocus + /> + + + onBaseFieldChange('maker', e.target.value)} + placeholder="メーカー" + /> + + + onPesticideFieldChange('registration_no', e.target.value)} + placeholder="登録番号" + /> + + + onPesticideFieldChange('formulation', e.target.value)} + placeholder="剤型" + /> + + + onPesticideFieldChange('active_ingredient', e.target.value)} + placeholder="有効成分" + /> + + + onPesticideFieldChange('category', e.target.value)} + placeholder="分類" + /> + + + onBaseFieldChange('stock_unit', value)} + /> + + + onBaseFieldChange('notes', e.target.value)} + placeholder="備考" + /> + + + onBaseFieldChange('is_active', e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500" + /> + + + + + + ); + } + + return ( + + + onBaseFieldChange('name', e.target.value)} + placeholder="資材名" + autoFocus + /> + + + + + + onBaseFieldChange('maker', e.target.value)} + placeholder="メーカー" + /> + + + onBaseFieldChange('stock_unit', value)} + /> + + + onBaseFieldChange('notes', e.target.value)} + placeholder="備考" + /> + + + onBaseFieldChange('is_active', e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500" + /> + + + + + + ); +} + +function ActionButtons({ + onSave, + onCancel, + saving, +}: { + onSave: () => void; + onCancel: () => void; + saving: boolean; +}) { + return ( +
+ + +
+ ); +} + +function StockUnitSelect({ + value, + onChange, +}: { + value: Material['stock_unit']; + onChange: (value: Material['stock_unit']) => void; +}) { + return ( + + ); +} diff --git a/frontend/src/app/materials/_components/StockOverview.tsx b/frontend/src/app/materials/_components/StockOverview.tsx new file mode 100644 index 0000000..1925a1e --- /dev/null +++ b/frontend/src/app/materials/_components/StockOverview.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { Fragment } from 'react'; +import { Clock3, Download, Upload } from 'lucide-react'; + +import { StockSummary, StockTransaction } from '@/types'; + +interface StockOverviewProps { + loading: boolean; + items: StockSummary[]; + expandedMaterialId: number | null; + historyLoadingId: number | null; + histories: Record; + onOpenTransaction: ( + materialId: number, + transactionType: StockTransaction['transaction_type'] + ) => void; + onToggleHistory: (materialId: number) => void; +} + +export default function StockOverview({ + loading, + items, + expandedMaterialId, + historyLoadingId, + histories, + onOpenTransaction, + onToggleHistory, +}: StockOverviewProps) { + if (loading) { + return

読み込み中...

; + } + + if (items.length === 0) { + return ( +
+ 表示できる在庫データがありません。 +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {items.map((item) => { + const isExpanded = expandedMaterialId === item.material_id; + const history = histories[item.material_id] ?? []; + + return ( + + + + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
資材名種別メーカー現在庫単位最終入出庫日操作
+
+ {item.name} + {!item.is_active && ( + + 無効 + + )} +
+
{item.material_type_display}{item.maker || '-'} + {item.current_stock} + {item.stock_unit_display} + {item.last_transaction_date ?? '-'} + +
+ + + +
+
+
+
+

+ {item.name} の入出庫履歴 +

+ {historyLoadingId === item.material_id && ( + 読み込み中... + )} +
+ {history.length === 0 ? ( +

履歴はまだありません。

+ ) : ( +
+ {history.map((transaction) => ( +
+
+ + {transaction.transaction_type_display} + + + {transaction.quantity} {transaction.stock_unit_display} + + {transaction.occurred_on} +
+ + {transaction.note || '備考なし'} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/materials/_components/StockTransactionForm.tsx b/frontend/src/app/materials/_components/StockTransactionForm.tsx new file mode 100644 index 0000000..839c589 --- /dev/null +++ b/frontend/src/app/materials/_components/StockTransactionForm.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, X } from 'lucide-react'; + +import { api } from '@/lib/api'; +import { Material, StockTransaction } from '@/types'; + +type TransactionType = StockTransaction['transaction_type']; + +interface StockTransactionFormProps { + isOpen: boolean; + materials: Material[]; + presetMaterialId?: number | null; + presetTransactionType?: TransactionType | null; + onClose: () => void; + onSaved: () => Promise | void; +} + +const transactionOptions: { value: TransactionType; label: string }[] = [ + { value: 'purchase', label: '入庫' }, + { value: 'use', label: '使用' }, + { value: 'adjustment_plus', label: '棚卸増' }, + { value: 'adjustment_minus', label: '棚卸減' }, + { value: 'discard', label: '廃棄' }, +]; + +const today = () => new Date().toISOString().slice(0, 10); + +export default function StockTransactionForm({ + isOpen, + materials, + presetMaterialId = null, + presetTransactionType = null, + onClose, + onSaved, +}: StockTransactionFormProps) { + const [materialId, setMaterialId] = useState(''); + const [transactionType, setTransactionType] = useState('purchase'); + const [quantity, setQuantity] = useState(''); + const [occurredOn, setOccurredOn] = useState(today()); + const [note, setNote] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) { + return; + } + setMaterialId(presetMaterialId ? String(presetMaterialId) : ''); + setTransactionType(presetTransactionType ?? 'purchase'); + setQuantity(''); + setOccurredOn(today()); + setNote(''); + setError(null); + }, [isOpen, presetMaterialId, presetTransactionType]); + + if (!isOpen) { + return null; + } + + const handleSave = async () => { + setError(null); + + if (!materialId) { + setError('資材を選択してください。'); + return; + } + if (!quantity || Number(quantity) <= 0) { + setError('数量は0より大きい値を入力してください。'); + return; + } + + setSaving(true); + try { + await api.post('/materials/stock-transactions/', { + material: Number(materialId), + transaction_type: transactionType, + quantity, + occurred_on: occurredOn, + note, + }); + await onSaved(); + onClose(); + } catch (e: unknown) { + 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); + } + }; + + return ( +
+
+
+
+

入出庫登録

+

在庫の増減を記録します。

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + + +
+ + + +
+ + + +