570 lines
21 KiB
Python
570 lines
21 KiB
Python
"""
|
|
Inventory app forms with healthcare-focused validation and user experience.
|
|
"""
|
|
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils import timezone
|
|
from datetime import date, timedelta
|
|
|
|
from .models import (
|
|
InventoryItem, InventoryStock, InventoryLocation,
|
|
PurchaseOrder, PurchaseOrderItem, Supplier
|
|
)
|
|
|
|
|
|
class SupplierForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating suppliers.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Supplier
|
|
fields = [
|
|
'name', 'supplier_code', 'supplier_type', 'contact_person',
|
|
'email', 'phone', 'address_line_1', 'address_line_2', 'city', 'state', 'postal_code',
|
|
'country', 'tax_id', 'payment_terms',
|
|
'is_preferred', 'is_active', 'notes'
|
|
]
|
|
widgets = {
|
|
'name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Enter supplier name'
|
|
}),
|
|
'supplier_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Unique supplier code'
|
|
}),
|
|
'supplier_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'contact_person': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Primary contact person'
|
|
}),
|
|
'email': forms.EmailInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'supplier@example.com'
|
|
}),
|
|
'phone': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': '+1-555-123-4567'
|
|
}),
|
|
'address_line_1': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Street address line 1'
|
|
}),
|
|
'address_line_2': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Street address line 2 (optional)'
|
|
}),
|
|
'city': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'City'
|
|
}),
|
|
'state': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'State/Province'
|
|
}),
|
|
'postal_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Postal/ZIP code'
|
|
}),
|
|
'country': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Country'
|
|
}),
|
|
'tax_id': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Tax ID/VAT number'
|
|
}),
|
|
'payment_terms': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0',
|
|
'max': '365'
|
|
}),
|
|
'is_preferred': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Additional notes about this supplier'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'supplier_code': 'Unique code to identify this supplier',
|
|
'payment_terms': 'Payment terms in days (e.g., Net 30)',
|
|
'credit_limit': 'Maximum credit limit for this supplier',
|
|
'is_preferred': 'Mark as preferred supplier for priority ordering',
|
|
}
|
|
|
|
def clean_supplier_code(self):
|
|
supplier_code = self.cleaned_data.get('supplier_code')
|
|
if supplier_code:
|
|
# Check for uniqueness within tenant (excluding current instance)
|
|
queryset = Supplier.objects.filter(supplier_code=supplier_code)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Supplier code must be unique.')
|
|
|
|
return supplier_code
|
|
|
|
def clean_email(self):
|
|
email = self.cleaned_data.get('email')
|
|
if email:
|
|
# Basic email validation is handled by EmailField
|
|
# Additional custom validation can be added here
|
|
pass
|
|
return email
|
|
|
|
def clean_credit_limit(self):
|
|
credit_limit = self.cleaned_data.get('credit_limit')
|
|
if credit_limit is not None and credit_limit < 0:
|
|
raise ValidationError('Credit limit cannot be negative.')
|
|
return credit_limit
|
|
|
|
|
|
class InventoryLocationForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating inventory locations.
|
|
"""
|
|
|
|
class Meta:
|
|
model = InventoryLocation
|
|
fields = [
|
|
'name', 'location_code', 'location_type', 'description',
|
|
'floor', 'room', 'temperature_controlled', 'secure_location',
|
|
'capacity_cubic_feet', 'is_active'
|
|
]
|
|
widgets = {
|
|
'name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Enter location name'
|
|
}),
|
|
'location_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Unique location code'
|
|
}),
|
|
'location_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Describe this location'
|
|
}),
|
|
'floor': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Floor number or level'
|
|
}),
|
|
'room': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Room or area number'
|
|
}),
|
|
'temperature_controlled': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'secure_location': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'capacity_cubic_feet': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0'
|
|
}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
}
|
|
help_texts = {
|
|
'location_code': 'Unique code to identify this location',
|
|
'temperature_controlled': 'Check if this location has temperature control',
|
|
'secure_location': 'Check if this location requires security access',
|
|
'capacity_cubic_feet': 'Maximum capacity of this location in cubic feet (optional)',
|
|
}
|
|
|
|
def clean_location_code(self):
|
|
location_code = self.cleaned_data.get('location_code')
|
|
if location_code:
|
|
# Check for uniqueness within tenant (excluding current instance)
|
|
queryset = InventoryLocation.objects.filter(location_code=location_code)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Location code must be unique.')
|
|
|
|
return location_code
|
|
|
|
def clean_capacity(self):
|
|
capacity = self.cleaned_data.get('capacity')
|
|
if capacity is not None and capacity < 0:
|
|
raise ValidationError('Capacity cannot be negative.')
|
|
return capacity
|
|
|
|
|
|
class InventoryItemForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating inventory items.
|
|
"""
|
|
|
|
class Meta:
|
|
model = InventoryItem
|
|
fields = [
|
|
'item_name', 'item_code', 'description', 'category', 'unit_of_measure',
|
|
'manufacturer', 'model_number', 'storage_requirements', 'reorder_point',
|
|
'reorder_quantity', 'unit_cost', 'controlled_substance',
|
|
'fda_approved', 'is_active', 'notes'
|
|
]
|
|
widgets = {
|
|
'item_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Enter item name'
|
|
}),
|
|
'item_code': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Unique item code or SKU'
|
|
}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Detailed description of the item'
|
|
}),
|
|
'category': forms.Select(attrs={'class': 'form-control'}),
|
|
'unit_of_measure': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., each, box, bottle, mg'
|
|
}),
|
|
'manufacturer': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Manufacturer name'
|
|
}),
|
|
'model_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Model or catalog number'
|
|
}),
|
|
'storage_requirements': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Storage requirements and conditions'
|
|
}),
|
|
'reorder_point': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0'
|
|
}),
|
|
'reorder_quantity': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '1'
|
|
}),
|
|
'unit_cost': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'controlled_substance': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'fda_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Additional notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'item_code': 'Unique code to identify this item (SKU, barcode, etc.)',
|
|
'reorder_point': 'Minimum stock level that triggers reordering',
|
|
'reorder_quantity': 'Quantity to order when reorder point is reached',
|
|
'unit_cost': 'Standard unit cost for this item',
|
|
'controlled_substance': 'Check if this is a controlled substance',
|
|
'fda_approved': 'Check if this item is FDA approved',
|
|
}
|
|
|
|
def clean_item_code(self):
|
|
item_code = self.cleaned_data.get('item_code')
|
|
if item_code:
|
|
# Check for uniqueness within tenant (excluding current instance)
|
|
queryset = InventoryItem.objects.filter(item_code=item_code)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Item code must be unique.')
|
|
|
|
return item_code
|
|
|
|
def clean_reorder_point(self):
|
|
reorder_point = self.cleaned_data.get('reorder_point')
|
|
if reorder_point is not None and reorder_point < 0:
|
|
raise ValidationError('Reorder point cannot be negative.')
|
|
return reorder_point
|
|
|
|
def clean_reorder_quantity(self):
|
|
reorder_quantity = self.cleaned_data.get('reorder_quantity')
|
|
if reorder_quantity is not None and reorder_quantity <= 0:
|
|
raise ValidationError('Reorder quantity must be greater than zero.')
|
|
return reorder_quantity
|
|
|
|
def clean_unit_cost(self):
|
|
unit_cost = self.cleaned_data.get('unit_cost')
|
|
if unit_cost is not None and unit_cost < 0:
|
|
raise ValidationError('Unit cost cannot be negative.')
|
|
return unit_cost
|
|
|
|
|
|
class InventoryStockForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating inventory stock.
|
|
"""
|
|
|
|
class Meta:
|
|
model = InventoryStock
|
|
fields = [
|
|
'inventory_item', 'location', 'quantity_on_hand', 'quantity_reserved',
|
|
'unit_cost', 'lot_number',
|
|
'expiration_date', 'notes'
|
|
]
|
|
widgets = {
|
|
'inventory_item': forms.Select(attrs={'class': 'form-control'}),
|
|
'location': forms.Select(attrs={'class': 'form-control'}),
|
|
'quantity_on_hand': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0'
|
|
}),
|
|
'quantity_reserved': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0'
|
|
}),
|
|
'unit_cost': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'lot_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Lot or batch number'
|
|
}),
|
|
'expiration_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Additional notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'quantity_on_hand': 'Current quantity in stock',
|
|
'quantity_reserved': 'Quantity reserved for orders',
|
|
'unit_cost': 'Cost per unit for this stock',
|
|
'lot_number': 'Lot or batch number for tracking',
|
|
'expiration_date': 'Expiration date (if applicable)',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter items and locations by tenant
|
|
self.fields['inventory_item'].queryset = InventoryItem.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('item_name')
|
|
|
|
self.fields['location'].queryset = InventoryLocation.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
def clean_quantity_on_hand(self):
|
|
quantity_on_hand = self.cleaned_data.get('quantity_on_hand')
|
|
if quantity_on_hand is not None and quantity_on_hand < 0:
|
|
raise ValidationError('Quantity cannot be negative.')
|
|
return quantity_on_hand
|
|
|
|
def clean_minimum_stock_level(self):
|
|
minimum_level = self.cleaned_data.get('minimum_stock_level')
|
|
if minimum_level is not None and minimum_level < 0:
|
|
raise ValidationError('Minimum stock level cannot be negative.')
|
|
return minimum_level
|
|
|
|
def clean_maximum_stock_level(self):
|
|
maximum_level = self.cleaned_data.get('maximum_stock_level')
|
|
if maximum_level is not None and maximum_level < 0:
|
|
raise ValidationError('Maximum stock level cannot be negative.')
|
|
return maximum_level
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
minimum_level = cleaned_data.get('minimum_stock_level')
|
|
maximum_level = cleaned_data.get('maximum_stock_level')
|
|
|
|
# Validate min/max relationship
|
|
if minimum_level and maximum_level:
|
|
if maximum_level <= minimum_level:
|
|
raise ValidationError('Maximum stock level must be greater than minimum stock level.')
|
|
|
|
# Validate expiry date
|
|
expiry_date = cleaned_data.get('expiry_date')
|
|
if expiry_date and expiry_date <= timezone.now().date():
|
|
# Allow past dates but warn about expired items
|
|
pass
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class PurchaseOrderForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating purchase orders.
|
|
"""
|
|
|
|
class Meta:
|
|
model = PurchaseOrder
|
|
fields = [
|
|
'supplier', 'order_date', 'requested_delivery_date',
|
|
'delivery_location', 'priority', 'payment_terms',
|
|
'shipping_amount', 'notes'
|
|
]
|
|
widgets = {
|
|
'supplier': forms.Select(attrs={'class': 'form-control'}),
|
|
'order_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'requested_delivery_date': forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date'
|
|
}),
|
|
'delivery_location': forms.Select(attrs={'class': 'form-control'}),
|
|
'priority': forms.Select(attrs={'class': 'form-control'}),
|
|
'payment_terms': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '0',
|
|
'max': '365'
|
|
}),
|
|
'shipping_amount': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0',
|
|
'placeholder': 'Shipping cost'
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Special instructions or notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'order_date': 'Date when the order is placed',
|
|
'requested_delivery_date': 'Requested delivery date',
|
|
'delivery_location': 'Location where items will be delivered',
|
|
'priority': 'Priority level for this order',
|
|
'payment_terms': 'Payment terms in days (e.g., Net 30)',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter suppliers and locations by tenant
|
|
self.fields['supplier'].queryset = Supplier.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
self.fields['delivery_location'].queryset = InventoryLocation.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
def clean_order_date(self):
|
|
order_date = self.cleaned_data.get('order_date')
|
|
if order_date and order_date > timezone.now().date():
|
|
# Allow future dates for planned orders
|
|
pass
|
|
return order_date
|
|
|
|
def clean_expected_delivery_date(self):
|
|
expected_date = self.cleaned_data.get('expected_delivery_date')
|
|
order_date = self.cleaned_data.get('order_date')
|
|
|
|
if expected_date and order_date:
|
|
if expected_date < order_date:
|
|
raise ValidationError('Expected delivery date cannot be before order date.')
|
|
|
|
return expected_date
|
|
|
|
def clean_payment_terms(self):
|
|
payment_terms = self.cleaned_data.get('payment_terms')
|
|
if payment_terms is not None and payment_terms < 0:
|
|
raise ValidationError('Payment terms cannot be negative.')
|
|
return payment_terms
|
|
|
|
|
|
class PurchaseOrderItemForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating purchase order items.
|
|
"""
|
|
|
|
class Meta:
|
|
model = PurchaseOrderItem
|
|
fields = [
|
|
'inventory_item', 'quantity_ordered', 'unit_price', 'notes'
|
|
]
|
|
widgets = {
|
|
'inventory_item': forms.Select(attrs={'class': 'form-control'}),
|
|
'quantity_ordered': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': '1'
|
|
}),
|
|
'unit_price': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'step': '0.01',
|
|
'min': '0'
|
|
}),
|
|
'notes': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 2,
|
|
'placeholder': 'Item-specific notes'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'quantity_ordered': 'Quantity to order',
|
|
'unit_price': 'Price per unit',
|
|
'notes': 'Special instructions for this item',
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop('user', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if user and hasattr(user, 'tenant'):
|
|
# Filter items by tenant
|
|
self.fields['inventory_item'].queryset = InventoryItem.objects.filter(
|
|
tenant=user.tenant,
|
|
is_active=True
|
|
).order_by('item_name')
|
|
|
|
def clean_quantity_ordered(self):
|
|
quantity_ordered = self.cleaned_data.get('quantity_ordered')
|
|
if quantity_ordered is not None and quantity_ordered <= 0:
|
|
raise ValidationError('Quantity must be greater than zero.')
|
|
return quantity_ordered
|
|
|
|
def clean_unit_price(self):
|
|
unit_price = self.cleaned_data.get('unit_price')
|
|
if unit_price is not None and unit_price < 0:
|
|
raise ValidationError('Unit price cannot be negative.')
|
|
return unit_price
|
|
|
|
|
|
# ============================================================================
|
|
# INLINE FORMSETS FOR RELATED MODELS
|
|
# ============================================================================
|
|
|
|
from django.forms import inlineformset_factory
|
|
|
|
# Purchase Order Items formset
|
|
PurchaseOrderItemFormSet = inlineformset_factory(
|
|
PurchaseOrder,
|
|
PurchaseOrderItem,
|
|
form=PurchaseOrderItemForm,
|
|
extra=1,
|
|
can_delete=True,
|
|
fields=['inventory_item', 'quantity_ordered', 'unit_price', 'notes']
|
|
)
|
|
|