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