在庫管理機能 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:
Akira
2026-03-14 15:42:47 +09:00
parent 67d4197b7f
commit 497bc87c24
20 changed files with 2344 additions and 1 deletions

View 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}'
)