""" Inventory app models for hospital management system. Provides medical supply management, equipment tracking, and procurement workflows. """ import uuid from django.db import models from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator from django.utils import timezone from django.conf import settings from datetime import timedelta, datetime, date from decimal import Decimal import json class InventoryItem(models.Model): """ Inventory item model for medical supplies and equipment. """ class ItemCategory(models.TextChoices): MEDICAL_SUPPLIES = 'MEDICAL_SUPPLIES', 'Medical Supplies' PHARMACEUTICALS = 'PHARMACEUTICALS', 'Pharmaceuticals' SURGICAL_INSTRUMENTS = 'SURGICAL_INSTRUMENTS', 'Surgical Instruments' DIAGNOSTIC_EQUIPMENT = 'DIAGNOSTIC_EQUIPMENT', 'Diagnostic Equipment' PATIENT_CARE = 'PATIENT_CARE', 'Patient Care Equipment' LABORATORY_SUPPLIES = 'LABORATORY_SUPPLIES', 'Laboratory Supplies' RADIOLOGY_SUPPLIES = 'RADIOLOGY_SUPPLIES', 'Radiology Supplies' OFFICE_SUPPLIES = 'OFFICE_SUPPLIES', 'Office Supplies' MAINTENANCE_SUPPLIES = 'MAINTENANCE_SUPPLIES', 'Maintenance Supplies' FOOD_NUTRITION = 'FOOD_NUTRITION', 'Food & Nutrition' LINENS_TEXTILES = 'LINENS_TEXTILES', 'Linens & Textiles' CLEANING_SUPPLIES = 'CLEANING_SUPPLIES', 'Cleaning Supplies' SAFETY_EQUIPMENT = 'SAFETY_EQUIPMENT', 'Safety Equipment' IT_EQUIPMENT = 'IT_EQUIPMENT', 'IT Equipment' FURNITURE = 'FURNITURE', 'Furniture' OTHER = 'OTHER', 'Other' class ItemType(models.TextChoices): CONSUMABLE = 'CONSUMABLE', 'Consumable' REUSABLE = 'REUSABLE', 'Reusable' EQUIPMENT = 'EQUIPMENT', 'Equipment' MEDICATION = 'MEDICATION', 'Medication' IMPLANT = 'IMPLANT', 'Implant' DEVICE = 'DEVICE', 'Medical Device' SUPPLY = 'SUPPLY', 'Supply' ASSET = 'ASSET', 'Asset' class UnitOfMeasure(models.TextChoices): EACH = 'EACH', 'Each' BOX = 'BOX', 'Box' CASE = 'CASE', 'Case' BOTTLE = 'BOTTLE', 'Bottle' VIAL = 'VIAL', 'Vial' TUBE = 'TUBE', 'Tube' PACK = 'PACK', 'Pack' KIT = 'KIT', 'Kit' ROLL = 'ROLL', 'Roll' SHEET = 'SHEET', 'Sheet' POUND = 'POUND', 'Pound' KILOGRAM = 'KILOGRAM', 'Kilogram' LITER = 'LITER', 'Liter' MILLILITER = 'MILLILITER', 'Milliliter' METER = 'METER', 'Meter' FOOT = 'FOOT', 'Foot' class PackageType(models.TextChoices): INDIVIDUAL = 'INDIVIDUAL', 'Individual' BULK = 'BULK', 'Bulk' STERILE = 'STERILE', 'Sterile Package' NON_STERILE = 'NON_STERILE', 'Non-Sterile Package' class DEASchedule(models.TextChoices): CI = 'CI', 'Schedule I' CII = 'CII', 'Schedule II' CIII = 'CIII', 'Schedule III' CIV = 'CIV', 'Schedule IV' CV = 'CV', 'Schedule V' # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', on_delete=models.CASCADE, related_name='inventory_items', help_text='Organization tenant' ) # Item Information item_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique item identifier' ) item_code = models.CharField( max_length=50, help_text='Internal item code' ) item_name = models.CharField( max_length=200, help_text='Item name' ) description = models.TextField( blank=True, null=True, help_text='Item description' ) # Item Classification category = models.CharField( max_length=30, choices=ItemCategory.choices, help_text='Item category' ) subcategory = models.CharField( max_length=50, blank=True, null=True, help_text='Item subcategory' ) # Item Type item_type = models.CharField( max_length=20, choices=ItemType.choices, help_text='Item type' ) # Manufacturer Information manufacturer = models.CharField( max_length=100, blank=True, null=True, help_text='Manufacturer name' ) model_number = models.CharField( max_length=50, blank=True, null=True, help_text='Model number' ) part_number = models.CharField( max_length=50, blank=True, null=True, help_text='Part number' ) # Identification Codes upc_code = models.CharField( max_length=20, blank=True, null=True, help_text='UPC barcode' ) ndc_code = models.CharField( max_length=20, blank=True, null=True, help_text='National Drug Code (for medications)' ) gtin_code = models.CharField( max_length=20, blank=True, null=True, help_text='Global Trade Item Number' ) # Unit Information unit_of_measure = models.CharField( max_length=20, choices=UnitOfMeasure.choices, default='EACH', help_text='Unit of measure' ) # Packaging Information package_size = models.PositiveIntegerField( default=1, help_text='Number of units per package' ) package_type = models.CharField( max_length=20, choices=PackageType.choices, default=PackageType.INDIVIDUAL, help_text='Package type' ) # Pricing Information unit_cost = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal('0.00'), help_text='Unit cost' ) list_price = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal('0.00'), help_text='List price' ) # Storage Requirements storage_temperature_min = models.DecimalField( max_digits=5, decimal_places=1, blank=True, null=True, help_text='Minimum storage temperature (Celsius)' ) storage_temperature_max = models.DecimalField( max_digits=5, decimal_places=1, blank=True, null=True, help_text='Maximum storage temperature (Celsius)' ) storage_humidity_min = models.PositiveIntegerField( blank=True, null=True, validators=[MaxValueValidator(100)], help_text='Minimum storage humidity (%)' ) storage_humidity_max = models.PositiveIntegerField( blank=True, null=True, validators=[MaxValueValidator(100)], help_text='Maximum storage humidity (%)' ) storage_requirements = models.TextField( blank=True, null=True, help_text='Special storage requirements' ) # Expiration Information has_expiration = models.BooleanField( default=False, help_text='Item has expiration date' ) shelf_life_days = models.PositiveIntegerField( blank=True, null=True, help_text='Shelf life in days' ) # Regulatory Information fda_approved = models.BooleanField( default=False, help_text='FDA approved' ) controlled_substance = models.BooleanField( default=False, help_text='Controlled substance' ) dea_schedule = models.CharField( max_length=5, choices=DEASchedule.choices, blank=True, null=True, help_text='DEA schedule (for controlled substances)' ) # Inventory Management is_active = models.BooleanField( default=True, help_text='Item is active' ) is_tracked = models.BooleanField( default=True, help_text='Track inventory levels' ) is_serialized = models.BooleanField( default=False, help_text='Track by serial number' ) is_lot_tracked = models.BooleanField( default=False, help_text='Track by lot number' ) # Reorder Information reorder_point = models.PositiveIntegerField( default=0, help_text='Reorder point quantity' ) reorder_quantity = models.PositiveIntegerField( default=0, help_text='Reorder quantity' ) min_stock_level = models.PositiveIntegerField( blank=True, null=True, help_text='Minimum stock level' ) max_stock_level = models.PositiveIntegerField( blank=True, null=True, help_text='Maximum stock level' ) # Supplier Information primary_supplier = models.ForeignKey( 'Supplier', on_delete=models.SET_NULL, null=True, blank=True, related_name='primary_items', help_text='Primary supplier' ) # Clinical Information clinical_use = models.TextField( blank=True, null=True, help_text='Clinical use and indications' ) contraindications = models.TextField( blank=True, null=True, help_text='Contraindications and warnings' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Additional notes' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_inventory_items', help_text='User who created the item' ) class Meta: db_table = 'inventory_item' verbose_name = 'Inventory Item' verbose_name_plural = 'Inventory Items' ordering = ['item_name'] indexes = [ models.Index(fields=['tenant', 'category']), models.Index(fields=['item_code']), models.Index(fields=['item_name']), models.Index(fields=['manufacturer']), models.Index(fields=['is_active']), ] unique_together = ['tenant', 'item_code'] def __str__(self): return f"{self.item_code} - {self.item_name}" @property def current_stock(self): """ Get current total stock across all locations. """ return sum(stock.quantity_on_hand for stock in self.inventory_stocks.all()) @property def total_value(self): """ Calculate total inventory value. """ return self.current_stock * self.unit_cost @property def needs_reorder(self): """ Check if item needs reordering. """ return self.current_stock <= self.reorder_point class InventoryStock(models.Model): """ Inventory stock model for tracking stock levels by location and lot. """ QUALITY_STATUS_CHOICES = [ ('GOOD', 'Good'), ('QUARANTINE', 'Quarantine'), ('EXPIRED', 'Expired'), ('DAMAGED', 'Damaged'), ('RECALLED', 'Recalled'), ('REJECTED', 'Rejected'), ] # Inventory Item relationship inventory_item = models.ForeignKey( InventoryItem, on_delete=models.CASCADE, related_name='inventory_stocks', help_text='Inventory item' ) # Location relationship location = models.ForeignKey( 'InventoryLocation', on_delete=models.CASCADE, related_name='inventory_stocks', help_text='Storage location' ) # Stock Information stock_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique stock identifier' ) # Lot Information lot_number = models.CharField( max_length=50, blank=True, null=True, help_text='Lot/batch number' ) serial_number = models.CharField( max_length=50, blank=True, null=True, help_text='Serial number' ) # Quantity Information quantity_on_hand = models.PositiveIntegerField( default=0, help_text='Quantity on hand' ) quantity_reserved = models.PositiveIntegerField( default=0, help_text='Quantity reserved' ) quantity_available = models.PositiveIntegerField( default=0, help_text='Quantity available (on hand - reserved)' ) # Dates received_date = models.DateField( blank=True, null=True, help_text='Date received' ) expiration_date = models.DateField( blank=True, null=True, help_text='Expiration date' ) # Cost Information unit_cost = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal('0.00'), help_text='Unit cost for this lot' ) total_cost = models.DecimalField( max_digits=12, decimal_places=2, default=Decimal('0.00'), help_text='Total cost (quantity × unit cost)' ) # Quality Information quality_status = models.CharField( max_length=20, choices=QUALITY_STATUS_CHOICES, default='GOOD', help_text='Quality status' ) # Supplier Information supplier = models.ForeignKey( 'Supplier', on_delete=models.SET_NULL, null=True, blank=True, related_name='inventory_stocks', help_text='Supplier for this stock' ) purchase_order = models.ForeignKey( 'PurchaseOrder', on_delete=models.SET_NULL, null=True, blank=True, related_name='inventory_stocks', help_text='Related purchase order' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Stock notes' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'inventory_stock' verbose_name = 'Inventory Stock' verbose_name_plural = 'Inventory Stocks' ordering = ['expiration_date'] indexes = [ models.Index(fields=['inventory_item', 'location']), models.Index(fields=['lot_number']), models.Index(fields=['serial_number']), models.Index(fields=['expiration_date']), models.Index(fields=['quality_status']), ] def __str__(self): return f"{self.inventory_item.item_name} - {self.location.name} ({self.quantity_on_hand})" def save(self, *args, **kwargs): """ Calculate derived fields. """ self.quantity_available = self.quantity_on_hand - self.quantity_reserved self.total_cost = self.quantity_on_hand * self.unit_cost super().save(*args, **kwargs) @property def tenant(self): """ Get tenant from inventory item. """ return self.inventory_item.tenant @property def is_expired(self): """ Check if stock is expired. """ if self.expiration_date: return self.expiration_date < timezone.now().date() return False @property def days_to_expiry(self): """ Calculate days to expiry. """ if self.expiration_date: return (self.expiration_date - timezone.now().date()).days return None class InventoryLocation(models.Model): """ Inventory location model for storage locations and areas. """ LOCATION_TYPE_CHOICES = [ ('WAREHOUSE', 'Warehouse'), ('STOREROOM', 'Storeroom'), ('PHARMACY', 'Pharmacy'), ('NURSING_UNIT', 'Nursing Unit'), ('OR_STORAGE', 'OR Storage'), ('LAB_STORAGE', 'Lab Storage'), ('RADIOLOGY', 'Radiology Storage'), ('CENTRAL_SUPPLY', 'Central Supply'), ('REFRIGERATOR', 'Refrigerator'), ('FREEZER', 'Freezer'), ('CONTROLLED', 'Controlled Substance'), ('QUARANTINE', 'Quarantine'), ('RECEIVING', 'Receiving'), ('SHIPPING', 'Shipping'), ('OTHER', 'Other'), ] ACCESS_CONTROL_CHOICES = [ ('OPEN', 'Open Access'), ('BADGE', 'Badge Access'), ('KEY', 'Key Access'), ('BIOMETRIC', 'Biometric Access'), ('DUAL_CONTROL', 'Dual Control'), ] # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', on_delete=models.CASCADE, related_name='inventory_locations', help_text='Organization tenant' ) # Location Information location_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique location identifier' ) location_code = models.CharField( max_length=20, help_text='Location code' ) name = models.CharField( max_length=100, help_text='Location name' ) description = models.TextField( blank=True, null=True, help_text='Location description' ) # Location Type location_type = models.CharField( max_length=20, choices=LOCATION_TYPE_CHOICES, help_text='Location type' ) # Physical Information building = models.CharField( max_length=50, blank=True, null=True, help_text='Building name/number' ) floor = models.CharField( max_length=10, blank=True, null=True, help_text='Floor' ) room = models.CharField( max_length=20, blank=True, null=True, help_text='Room number' ) zone = models.CharField( max_length=20, blank=True, null=True, help_text='Zone or area' ) aisle = models.CharField( max_length=10, blank=True, null=True, help_text='Aisle' ) shelf = models.CharField( max_length=10, blank=True, null=True, help_text='Shelf' ) bin = models.CharField( max_length=10, blank=True, null=True, help_text='Bin location' ) # Capacity Information capacity_cubic_feet = models.DecimalField( max_digits=10, decimal_places=2, blank=True, null=True, help_text='Capacity in cubic feet' ) max_weight_pounds = models.DecimalField( max_digits=10, decimal_places=2, blank=True, null=True, help_text='Maximum weight in pounds' ) # Environmental Controls temperature_controlled = models.BooleanField( default=False, help_text='Temperature controlled' ) temperature_min = models.DecimalField( max_digits=5, decimal_places=1, blank=True, null=True, help_text='Minimum temperature (Celsius)' ) temperature_max = models.DecimalField( max_digits=5, decimal_places=1, blank=True, null=True, help_text='Maximum temperature (Celsius)' ) humidity_controlled = models.BooleanField( default=False, help_text='Humidity controlled' ) humidity_min = models.PositiveIntegerField( blank=True, null=True, validators=[MaxValueValidator(100)], help_text='Minimum humidity (%)' ) humidity_max = models.PositiveIntegerField( blank=True, null=True, validators=[MaxValueValidator(100)], help_text='Maximum humidity (%)' ) # Security and Access secure_location = models.BooleanField( default=False, help_text='Secure/restricted access location' ) access_control = models.CharField( max_length=20, choices=ACCESS_CONTROL_CHOICES, default='OPEN', help_text='Access control method' ) # Location Status is_active = models.BooleanField( default=True, help_text='Location is active' ) # Parent Location (for hierarchical locations) parent_location = models.ForeignKey( 'self', on_delete=models.CASCADE, null=True, blank=True, related_name='child_locations', help_text='Parent location' ) # Responsible Staff location_manager = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_locations', help_text='Location manager' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Location notes' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_inventory_locations', help_text='User who created the location' ) class Meta: db_table = 'inventory_location' verbose_name = 'Inventory Location' verbose_name_plural = 'Inventory Locations' ordering = ['location_code'] indexes = [ models.Index(fields=['tenant', 'location_type']), models.Index(fields=['location_code']), models.Index(fields=['name']), models.Index(fields=['is_active']), ] unique_together = ['tenant', 'location_code'] def __str__(self): return f"{self.location_code} - {self.name}" @property def full_address(self): """ Get full location address. """ parts = [self.building, self.floor, self.room, self.zone, self.aisle, self.shelf, self.bin] return " / ".join([part for part in parts if part]) @property def total_items(self): """ Get total number of different items in location. """ return self.inventory_stocks.count() @property def total_quantity(self): """ Get total quantity of all items in location. """ return sum(stock.quantity_on_hand for stock in self.inventory_stocks.all()) class PurchaseOrder(models.Model): """ Purchase order model for procurement workflows. """ ORDER_TYPE_CHOICES = [ ('STANDARD', 'Standard Order'), ('RUSH', 'Rush Order'), ('EMERGENCY', 'Emergency Order'), ('BLANKET', 'Blanket Order'), ('CONTRACT', 'Contract Order'), ('CONSIGNMENT', 'Consignment'), ] PRIORITY_CHOICES = [ ('LOW', 'Low'), ('NORMAL', 'Normal'), ('HIGH', 'High'), ('URGENT', 'Urgent'), ] STATUS_CHOICES = [ ('DRAFT', 'Draft'), ('PENDING_APPROVAL', 'Pending Approval'), ('APPROVED', 'Approved'), ('SENT', 'Sent to Supplier'), ('ACKNOWLEDGED', 'Acknowledged'), ('PARTIAL_RECEIVED', 'Partially Received'), ('RECEIVED', 'Received'), ('INVOICED', 'Invoiced'), ('PAID', 'Paid'), ('CANCELLED', 'Cancelled'), ('CLOSED', 'Closed'), ] PAYMENT_TERMS_CHOICES = [ ('NET_30', 'Net 30 Days'), ('NET_60', 'Net 60 Days'), ('NET_90', 'Net 90 Days'), ('COD', 'Cash on Delivery'), ('PREPAID', 'Prepaid'), ('CREDIT_CARD', 'Credit Card'), ] # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', on_delete=models.CASCADE, related_name='purchase_orders', help_text='Organization tenant' ) # Purchase Order Information po_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique purchase order identifier' ) po_number = models.CharField( max_length=20, unique=True, help_text='Purchase order number' ) # Supplier Information supplier = models.ForeignKey( 'Supplier', on_delete=models.CASCADE, related_name='purchase_orders', help_text='Supplier' ) # Order Dates order_date = models.DateField( default=timezone.now, help_text='Order date' ) requested_delivery_date = models.DateField( blank=True, null=True, help_text='Requested delivery date' ) promised_delivery_date = models.DateField( blank=True, null=True, help_text='Promised delivery date' ) actual_delivery_date = models.DateField( blank=True, null=True, help_text='Actual delivery date' ) # Order Type order_type = models.CharField( max_length=20, choices=ORDER_TYPE_CHOICES, default='STANDARD', help_text='Order type' ) # Priority priority = models.CharField( max_length=10, choices=PRIORITY_CHOICES, default='NORMAL', help_text='Order priority' ) # Financial Information subtotal = models.DecimalField( max_digits=12, decimal_places=2, default=Decimal('0.00'), help_text='Subtotal amount' ) tax_amount = models.DecimalField( max_digits=12, decimal_places=2, default=Decimal('0.00'), help_text='Tax amount' ) shipping_amount = models.DecimalField( max_digits=12, decimal_places=2, default=Decimal('0.00'), help_text='Shipping amount' ) total_amount = models.DecimalField( max_digits=12, decimal_places=2, default=Decimal('0.00'), help_text='Total order amount' ) # Order Status status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='DRAFT', help_text='Order status' ) # Delivery Information delivery_location = models.ForeignKey( InventoryLocation, on_delete=models.SET_NULL, null=True, blank=True, related_name='purchase_orders', help_text='Delivery location' ) delivery_instructions = models.TextField( blank=True, null=True, help_text='Delivery instructions' ) # Payment Terms payment_terms = models.CharField( max_length=20, choices=PAYMENT_TERMS_CHOICES, default='NET_30', help_text='Payment terms' ) # Approval Information requested_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='requested_purchase_orders', help_text='User who requested the order' ) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_purchase_orders', help_text='User who approved the order' ) approval_date = models.DateTimeField( blank=True, null=True, help_text='Approval date and time' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Order notes and comments' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_purchase_orders', help_text='User who created the order' ) class Meta: db_table = 'inventory_purchase_order' verbose_name = 'Purchase Order' verbose_name_plural = 'Purchase Orders' ordering = ['-order_date'] indexes = [ models.Index(fields=['tenant', 'status']), models.Index(fields=['po_number']), models.Index(fields=['supplier']), models.Index(fields=['order_date']), models.Index(fields=['requested_delivery_date']), ] def __str__(self): return f"{self.po_number} - {self.supplier.name}" def save(self, *args, **kwargs): """ Generate PO number and calculate totals. """ if not self.po_number: # Generate PO number (simple implementation) today = timezone.now().date() last_po = PurchaseOrder.objects.filter( tenant=self.tenant, created_at__date=today ).order_by('-id').first() if last_po: last_number = int(last_po.po_number.split('-')[-1]) self.po_number = f"PO-{today.strftime('%Y%m%d')}-{last_number + 1:04d}" else: self.po_number = f"PO-{today.strftime('%Y%m%d')}-0001" # Calculate totals self.total_amount = self.subtotal + self.tax_amount + self.shipping_amount super().save(*args, **kwargs) @property def is_overdue(self): """ Check if order is overdue. """ if self.requested_delivery_date and self.status not in ['RECEIVED', 'CANCELLED', 'CLOSED']: return self.requested_delivery_date < timezone.now().date() return False @property def days_outstanding(self): """ Calculate days since order date. """ return (timezone.now().date() - self.order_date).days class PurchaseOrderItem(models.Model): """ Purchase order item model for individual line items. """ STATUS_CHOICES = [ ('PENDING', 'Pending'), ('ORDERED', 'Ordered'), ('PARTIAL_RECEIVED', 'Partially Received'), ('RECEIVED', 'Received'), ('CANCELLED', 'Cancelled'), ] # Purchase Order relationship purchase_order = models.ForeignKey( PurchaseOrder, on_delete=models.CASCADE, related_name='line_items', help_text='Purchase order' ) # Item Information item_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique item identifier' ) line_number = models.PositiveIntegerField( help_text='Line item number' ) # Inventory Item inventory_item = models.ForeignKey( InventoryItem, on_delete=models.CASCADE, related_name='purchase_order_items', help_text='Inventory item' ) # Quantity Information quantity_ordered = models.PositiveIntegerField( help_text='Quantity ordered' ) quantity_received = models.PositiveIntegerField( default=0, help_text='Quantity received' ) quantity_remaining = models.PositiveIntegerField( default=0, help_text='Quantity remaining to receive' ) # Pricing Information unit_price = models.DecimalField( max_digits=10, decimal_places=2, help_text='Unit price' ) total_price = models.DecimalField( max_digits=12, decimal_places=2, help_text='Total price (quantity × unit price)' ) # Delivery Information requested_delivery_date = models.DateField( blank=True, null=True, help_text='Requested delivery date for this item' ) # Item Status status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='PENDING', help_text='Item status' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Item notes' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'inventory_purchase_order_item' verbose_name = 'Purchase Order Item' verbose_name_plural = 'Purchase Order Items' ordering = ['line_number'] indexes = [ models.Index(fields=['purchase_order', 'line_number']), models.Index(fields=['inventory_item']), models.Index(fields=['status']), ] unique_together = ['purchase_order', 'line_number'] def __str__(self): return f"{self.purchase_order.po_number} - Line {self.line_number}" def save(self, *args, **kwargs): """ Calculate derived fields. """ self.total_price = self.quantity_ordered * self.unit_price self.quantity_remaining = self.quantity_ordered - self.quantity_received super().save(*args, **kwargs) @property def tenant(self): """ Get tenant from purchase order. """ return self.purchase_order.tenant @property def is_fully_received(self): """ Check if item is fully received. """ return self.quantity_received >= self.quantity_ordered class Supplier(models.Model): """ Supplier model for vendor management. """ SUPPLIER_TYPE_CHOICES = [ ('MANUFACTURER', 'Manufacturer'), ('DISTRIBUTOR', 'Distributor'), ('WHOLESALER', 'Wholesaler'), ('RETAILER', 'Retailer'), ('SERVICE', 'Service Provider'), ('CONSULTANT', 'Consultant'), ('OTHER', 'Other'), ] PAYMENT_TERMS_CHOICES = [ ('NET_30', 'Net 30 Days'), ('NET_60', 'Net 60 Days'), ('NET_90', 'Net 90 Days'), ('COD', 'Cash on Delivery'), ('PREPAID', 'Prepaid'), ('CREDIT_CARD', 'Credit Card'), ] # Tenant relationship tenant = models.ForeignKey( 'core.Tenant', on_delete=models.CASCADE, related_name='suppliers', help_text='Organization tenant' ) # Supplier Information supplier_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, help_text='Unique supplier identifier' ) supplier_code = models.CharField( max_length=20, help_text='Supplier code' ) name = models.CharField( max_length=200, help_text='Supplier name' ) # Supplier Type supplier_type = models.CharField( max_length=20, choices=SUPPLIER_TYPE_CHOICES, help_text='Supplier type' ) # Contact Information contact_person = models.CharField( max_length=100, blank=True, null=True, help_text='Primary contact person' ) phone = models.CharField( max_length=20, blank=True, null=True, help_text='Phone number' ) email = models.EmailField( blank=True, null=True, help_text='Email address' ) website = models.URLField( blank=True, null=True, help_text='Website URL' ) # Address Information address_line_1 = models.CharField( max_length=100, blank=True, null=True, help_text='Address line 1' ) address_line_2 = models.CharField( max_length=100, blank=True, null=True, help_text='Address line 2' ) city = models.CharField( max_length=50, blank=True, null=True, help_text='City' ) state = models.CharField( max_length=50, blank=True, null=True, help_text='State/Province' ) postal_code = models.CharField( max_length=20, blank=True, null=True, help_text='Postal/ZIP code' ) country = models.CharField( max_length=50, blank=True, null=True, help_text='Country' ) # Business Information tax_id = models.CharField( max_length=20, blank=True, null=True, help_text='Tax ID/EIN' ) duns_number = models.CharField( max_length=20, blank=True, null=True, help_text='DUNS number' ) # Payment Information payment_terms = models.CharField( max_length=20, choices=PAYMENT_TERMS_CHOICES, default='NET_30', help_text='Default payment terms' ) # Performance Information performance_rating = models.DecimalField( max_digits=3, decimal_places=1, default=Decimal('0.0'), validators=[MinValueValidator(0), MaxValueValidator(5)], help_text='Performance rating (0-5)' ) on_time_delivery_rate = models.DecimalField( max_digits=5, decimal_places=2, default=Decimal('0.00'), validators=[MinValueValidator(0), MaxValueValidator(100)], help_text='On-time delivery rate (%)' ) quality_rating = models.DecimalField( max_digits=3, decimal_places=1, default=Decimal('0.0'), validators=[MinValueValidator(0), MaxValueValidator(5)], help_text='Quality rating (0-5)' ) # Supplier Status is_active = models.BooleanField( default=True, help_text='Supplier is active' ) is_preferred = models.BooleanField( default=False, help_text='Preferred supplier' ) # Certification Information certifications = models.JSONField( default=list, help_text='Supplier certifications' ) # Contract Information contract_start_date = models.DateField( blank=True, null=True, help_text='Contract start date' ) contract_end_date = models.DateField( blank=True, null=True, help_text='Contract end date' ) # Notes notes = models.TextField( blank=True, null=True, help_text='Supplier notes' ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_suppliers', help_text='User who created the supplier' ) class Meta: db_table = 'inventory_supplier' verbose_name = 'Supplier' verbose_name_plural = 'Suppliers' ordering = ['name'] indexes = [ models.Index(fields=['tenant', 'supplier_type']), models.Index(fields=['supplier_code']), models.Index(fields=['name']), models.Index(fields=['is_active']), ] unique_together = ['tenant', 'supplier_code'] def __str__(self): return f"{self.supplier_code} - {self.name}" @property def full_address(self): """ Get full address. """ parts = [ self.address_line_1, self.address_line_2, f"{self.city}, {self.state} {self.postal_code}", self.country ] return "\n".join([part for part in parts if part]) @property def total_orders(self): """ Get total number of purchase orders. """ return self.purchase_orders.count() @property def total_order_value(self): """ Get total value of all purchase orders. """ return sum(po.total_amount for po in self.purchase_orders.all()) @property def has_active_contract(self): """ Check if supplier has active contract. """ if self.contract_start_date and self.contract_end_date: today = timezone.now().date() return self.contract_start_date <= today <= self.contract_end_date return False