Spaces:
Sleeping
Sleeping
File size: 15,170 Bytes
74de430 38ac151 db7b74e 38ac151 db7b74e 74de430 38ac151 74de430 33d9190 38ac151 74de430 db7b74e 38ac151 74de430 38ac151 db7b74e 38ac151 db7b74e 38ac151 db7b74e 38ac151 33d9190 38ac151 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | ο»Ώ"""
INVENTORY Models - 3-Tier Inventory Management
FLOW:
1. ProjectInventory: Client delivers equipment to main office
2. ProjectInventoryDistribution: Equipment distributed to regional hubs
3. InventoryAssignment: Individual units issued to field agents
4. InventoryTransfer: Agent-to-agent transfers (peer-to-peer)
Examples:
- TOOLS: Issued to worker β Used β Returned to hub
- EQUIPMENT: Issued to worker β Installed at customer site β Not returned
- CONSUMABLES: Issued to worker β Used up β No return
- PPE: Issued to worker β Long-term assignment
- TRANSFERS: Agent A β Agent B (direct peer-to-peer)
"""
from sqlalchemy import Column, String, Boolean, Integer, Text, Date, DateTime, Numeric, ForeignKey, CheckConstraint, UniqueConstraint, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from decimal import Decimal
from app.models.base import BaseModel
from app.models.enums import InventoryItemType, EquipmentStatus, TransferStatus
class ProjectInventory(BaseModel):
"""
Main Office Inventory - Equipment received from clients
First tier: Tracks equipment batches received at main office.
Client delivers equipment (ONTs, routers, cables, tools) for project.
Main office distributes to regional hubs via ProjectInventoryDistribution.
Features:
- Item classification (tool, equipment, consumable, ppe)
- Quantity tracking (received, distributed, at_office)
- Serial number management (JSONB array)
- Valuation (unit_cost, total_cost)
- Optimistic locking (version field)
"""
__tablename__ = "project_inventory"
# Relationships
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
# Item Classification
item_type = Column(String, nullable=False) # InventoryItemType enum
equipment_type = Column(Text, nullable=False) # 'ONT', 'Router', 'Cable', 'Drill', etc.
equipment_name = Column(Text, nullable=False) # Specific model/name
description = Column(Text)
# Quantity Tracking (main office level)
quantity_received = Column(Numeric(10, 2), nullable=False)
quantity_distributed = Column(Numeric(10, 2), default=0)
# quantity_at_office computed in database: quantity_received - quantity_distributed
# Unit of Measurement
unit = Column(Text, default="pieces") # 'pieces', 'meters', 'boxes', 'sets'
# Serial Numbers (if applicable)
has_serial_numbers = Column(Boolean, default=False)
serial_numbers = Column(JSONB) # Array of serials with status
# Receiving Details
received_date = Column(Date, nullable=False)
received_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
delivery_note_reference = Column(Text)
# Valuation
unit_cost = Column(Numeric(12, 2))
total_cost = Column(Numeric(12, 2))
currency = Column(Text, default="KES")
# Status
status = Column(String, default="received") # EquipmentStatus enum
is_active = Column(Boolean, default=True)
# Metadata
notes = Column(Text)
# Optimistic Locking
version = Column(Integer, default=1, nullable=False)
# Relationships
project = relationship("Project", back_populates="inventory")
received_by = relationship("User", foreign_keys=[received_by_user_id])
distributions = relationship("ProjectInventoryDistribution", back_populates="inventory", lazy="dynamic")
# Table constraints
__table_args__ = (
CheckConstraint("quantity_received >= 0 AND quantity_distributed >= 0", name="chk_positive_quantities"),
CheckConstraint("quantity_distributed <= quantity_received", name="chk_valid_distributed"),
CheckConstraint("(unit_cost IS NULL OR unit_cost >= 0) AND (total_cost IS NULL OR total_cost >= 0)", name="chk_positive_costs"),
Index("idx_project_inventory_project", "project_id", "item_type", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_project_inventory_status", "status", "is_active", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_project_inventory_version", "id", "version", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_project_inventory_type", "equipment_type", "is_active"),
)
@property
def quantity_at_office(self) -> Decimal:
"""Calculate quantity remaining at main office"""
return Decimal(str(self.quantity_received)) - Decimal(str(self.quantity_distributed))
@property
def is_fully_distributed(self) -> bool:
"""Check if all inventory distributed to regional hubs"""
return self.quantity_at_office <= 0
@property
def distribution_percentage(self) -> float:
"""Calculate percentage of inventory distributed"""
if not self.quantity_received or self.quantity_received == 0:
return 0.0
return float((self.quantity_distributed / self.quantity_received) * 100)
def __repr__(self):
return f"<ProjectInventory(id={self.id}, type={self.equipment_type}, received={self.quantity_received}, at_office={self.quantity_at_office})>"
class ProjectInventoryDistribution(BaseModel):
"""
Regional Hub Inventory - Distribution from main office
Second tier: Tracks inventory allocated to regional hubs.
Main office distributes equipment batches to regional hubs.
Field agents collect equipment from their assigned regional hub.
Features:
- Quantity tracking (allocated, issued, installed, returned, lost, damaged, available)
- Serial number subset tracking (from parent ProjectInventory)
- Regional hub assignment
- Optimistic locking
"""
__tablename__ = "project_inventory_distribution"
# Relationships
project_inventory_id = Column(UUID(as_uuid=True), ForeignKey("project_inventory.id", ondelete="CASCADE"), nullable=False)
project_region_id = Column(UUID(as_uuid=True), ForeignKey("project_regions.id", ondelete="CASCADE"), nullable=False)
# Quantity Tracking (regional hub level)
quantity_allocated = Column(Numeric(10, 2), nullable=False) # Sent to this hub
quantity_issued = Column(Numeric(10, 2), default=0) # Issued to field agents
quantity_installed = Column(Numeric(10, 2), default=0) # Installed (equipment only)
quantity_returned = Column(Numeric(10, 2), default=0) # Returned (tools only)
quantity_lost = Column(Numeric(10, 2), default=0)
quantity_damaged = Column(Numeric(10, 2), default=0)
# quantity_available computed in database: quantity_allocated - quantity_issued + quantity_returned
# Distribution Details
allocated_date = Column(Date, nullable=False)
allocated_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
received_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")) # Regional manager
# Serial Numbers (subset from parent)
serial_numbers = Column(JSONB) # Array of serials at this hub
# Status
is_active = Column(Boolean, default=True)
# Metadata
notes = Column(Text)
# Optimistic Locking
version = Column(Integer, default=1, nullable=False)
# Relationships
inventory = relationship("ProjectInventory", back_populates="distributions")
region = relationship("ProjectRegion", back_populates="inventory_distributions")
allocated_by = relationship("User", foreign_keys=[allocated_by_user_id])
received_by = relationship("User", foreign_keys=[received_by_user_id])
assignments = relationship("InventoryAssignment", back_populates="distribution", lazy="dynamic")
# Table constraints
__table_args__ = (
CheckConstraint(
"quantity_allocated >= 0 AND quantity_issued >= 0 AND quantity_installed >= 0 AND quantity_returned >= 0 AND quantity_lost >= 0 AND quantity_damaged >= 0",
name="chk_positive_distribution_quantities"
),
CheckConstraint("quantity_issued <= quantity_allocated + quantity_returned", name="chk_valid_issued_distribution"),
Index("idx_inventory_distribution_inventory", "project_inventory_id", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_distribution_region", "project_region_id", "is_active", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_distribution_version", "id", "version", postgresql_where=(Column("deleted_at").is_(None))),
)
@property
def quantity_available(self) -> Decimal:
"""Calculate quantity available at this regional hub"""
return Decimal(str(self.quantity_allocated)) - Decimal(str(self.quantity_issued)) + Decimal(str(self.quantity_returned))
@property
def is_fully_issued(self) -> bool:
"""Check if all inventory issued to field agents"""
return self.quantity_available <= 0
@property
def utilization_percentage(self) -> float:
"""Calculate percentage of inventory issued"""
if not self.quantity_allocated or self.quantity_allocated == 0:
return 0.0
return float((self.quantity_issued / self.quantity_allocated) * 100)
def __repr__(self):
return f"<ProjectInventoryDistribution(id={self.id}, region={self.project_region_id}, allocated={self.quantity_allocated}, available={self.quantity_available})>"
class InventoryAssignment(BaseModel):
"""
Field Agent Assignments - Individual units issued to workers
Third tier: Tracks individual inventory units issued to field agents.
Each assignment = ONE unit/box with unique identifier.
FLOW:
- TOOLS: Worker collects β Uses β Returns to hub
- EQUIPMENT: Worker collects β Installs at customer site β Not returned
- CONSUMABLES: Worker collects β Uses up β No return
- PPE: Worker collects β Long-term assignment
Features:
- Unit/box identity tracking (serial numbers or unique IDs)
- Timeline tracking (issued, returned, installed, consumed)
- Return condition tracking (for tools)
- Ticket linkage (for equipment/consumables)
- Optimistic locking
- Unique constraint: One unit identifier per distribution
"""
__tablename__ = "inventory_assignments"
# Relationships
project_inventory_distribution_id = Column(UUID(as_uuid=True), ForeignKey("project_inventory_distribution.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False) # Field agent
ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"))
# Unit/Box Identity (ONE unit per assignment)
unit_identifier = Column(Text, nullable=False) # Serial number, box number, or unique ID
unit_contents = Column(JSONB) # OPTIONAL: Array of items inside box/unit
# Timeline
issued_at = Column(DateTime(timezone=True), default=datetime.utcnow)
issued_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
# Return/Usage (mutually exclusive based on item type)
returned_at = Column(DateTime(timezone=True)) # For tools only
return_condition = Column(Text) # 'good', 'damaged', 'lost', 'worn'
returned_to_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
return_notes = Column(Text)
is_returned = Column(Boolean, default=False)
installed_at = Column(DateTime(timezone=True)) # For equipment only
consumed_at = Column(DateTime(timezone=True)) # For consumables only
# Status
status = Column(String, default="issued") # EquipmentStatus enum
# Notes
notes = Column(Text)
additional_metadata = Column(JSONB, default={})
# Transfer tracking
transferred_from_assignment_id = Column(UUID(as_uuid=True), ForeignKey("inventory_assignments.id", ondelete="SET NULL"))
is_transferred = Column(Boolean, default=False)
transferred_at = Column(DateTime(timezone=True))
# Optimistic Locking
version = Column(Integer, default=1, nullable=False)
# Relationships
distribution = relationship("ProjectInventoryDistribution", back_populates="assignments")
user = relationship("User", foreign_keys=[user_id])
issued_by = relationship("User", foreign_keys=[issued_by_user_id])
returned_to = relationship("User", foreign_keys=[returned_to_user_id])
ticket = relationship("Ticket")
transferred_from = relationship("InventoryAssignment", foreign_keys=[transferred_from_assignment_id], remote_side="InventoryAssignment.id")
# Table constraints
__table_args__ = (
Index("idx_inventory_assignments_distribution", "project_inventory_distribution_id", "status", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_user", "user_id", "status", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_user_issued", "user_id", "issued_at", postgresql_where=(Column("status") == "issued") & (Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_ticket", "ticket_id", postgresql_where=(Column("ticket_id").isnot(None)) & (Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_unit", "unit_identifier", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_status", "status", "issued_at", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_version", "id", "version", postgresql_where=(Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_unreturned", "user_id", "is_returned", "issued_at", postgresql_where=(Column("is_returned") == False) & (Column("deleted_at").is_(None))),
Index("idx_inventory_assignments_transferred", "is_transferred", "transferred_at", postgresql_where=(Column("is_transferred") == True) & (Column("deleted_at").is_(None))),
UniqueConstraint("project_inventory_distribution_id", "unit_identifier", name="idx_inventory_assignments_unique_unit"),
)
@property
def is_active(self) -> bool:
"""Check if assignment is still active (not returned/installed/consumed)"""
return not (self.returned_at or self.installed_at or self.consumed_at)
@property
def days_issued(self) -> int:
"""Calculate days since issuance"""
if not self.issued_at:
return 0
end_time = self.returned_at or self.installed_at or self.consumed_at or datetime.now(timezone.utc)
return (end_time - self.issued_at).days
def can_return(self) -> bool:
"""Check if this assignment can be returned"""
return not (self.returned_at or self.installed_at or self.consumed_at)
def __repr__(self):
return f"<InventoryAssignment(id={self.id}, unit={self.unit_identifier}, user={self.user_id}, status={self.status})>"
|