1468 lines
39 KiB
Python
1468 lines
39 KiB
Python
"""
|
||
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'
|
||
PHARMACY_MEDICATIONS = 'PHARMACY_MEDICATIONS', 'Pharmacy Medications'
|
||
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
|