Marwan Alwali ab2c4a36c5 update
2025-10-02 10:13:03 +03:00

1468 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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