在庫管理機能 Phase 1 実装(apps/materials + フロントエンド)
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 <noreply@anthropic.com>
This commit is contained in:
1
backend/apps/materials/__init__.py
Normal file
1
backend/apps/materials/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
backend/apps/materials/admin.py
Normal file
28
backend/apps/materials/admin.py
Normal file
@@ -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']
|
||||
8
backend/apps/materials/apps.py
Normal file
8
backend/apps/materials/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MaterialsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.materials'
|
||||
verbose_name = '資材管理'
|
||||
|
||||
87
backend/apps/materials/migrations/0001_initial.py
Normal file
87
backend/apps/materials/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
1
backend/apps/materials/migrations/__init__.py
Normal file
1
backend/apps/materials/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
210
backend/apps/materials/models.py
Normal file
210
backend/apps/materials/models.py
Normal file
@@ -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}'
|
||||
)
|
||||
212
backend/apps/materials/serializers.py
Normal file
212
backend/apps/materials/serializers.py
Normal file
@@ -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)
|
||||
17
backend/apps/materials/urls.py
Normal file
17
backend/apps/materials/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
134
backend/apps/materials/views.py
Normal file
134
backend/apps/materials/views.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user