Spaces:
Sleeping
Sleeping
File size: 16,574 Bytes
74de430 38ac151 74de430 9dd1d74 74de430 38ac151 74de430 b5808a8 74de430 38ac151 dd53dfa 38ac151 6b29be6 38ac151 f18d2f1 38ac151 456b2e2 38ac151 6c59dab 38ac151 6c59dab 38ac151 6c59dab 38ac151 ae9649e 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 c43adb1 95005e1 c43adb1 95005e1 c43adb1 95005e1 c43adb1 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 38ac151 95005e1 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 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | """
PROJECT Models - Maps to projects, project_regions, project_roles, project_subcontractors tables
"""
from sqlalchemy import Column, String, Boolean, Integer, Text, Date, DateTime, Numeric, ForeignKey, CheckConstraint
from sqlalchemy.dialects.postgresql import UUID, JSONB, ENUM
from sqlalchemy.orm import relationship
from datetime import datetime
from typing import Optional, List, Dict, Any
from app.models.base import BaseModel
from app.models.enums import ProjectStatus, ProjectType, ServiceType, CompensationType
class Project(BaseModel):
"""
Project model - Central collaboration entity between Client and Contractor
Maps to 'projects' table in docs/schema/schema.sql
A project represents a specific agreement/work being executed between:
- Client (telecom operator who needs field work done)
- Contractor (company that executes the field work)
"""
__tablename__ = "projects"
# Organization Links - Core Relationship
client_id = Column(UUID(as_uuid=True), ForeignKey('clients.id', ondelete='RESTRICT'), nullable=False)
contractor_id = Column(UUID(as_uuid=True), ForeignKey('contractors.id', ondelete='RESTRICT'), nullable=False)
# Project Identity
title = Column(Text, nullable=False)
description = Column(Text, nullable=True)
project_type = Column(
ENUM(ProjectType, name='projecttype', create_type=False, values_callable=lambda x: [e.value for e in x]),
nullable=False
)
service_type = Column(String(50), nullable=True) # ServiceType enum in DB
# Project Management
primary_manager_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Project Lifecycle
status = Column(String(50), default='planning', nullable=False) # ProjectStatus enum in DB
planned_start_date = Column(Date, nullable=True)
planned_end_date = Column(Date, nullable=True)
actual_start_date = Column(Date, nullable=True)
actual_end_date = Column(Date, nullable=True)
is_closed = Column(Boolean, default=False, nullable=False)
closed_at = Column(DateTime, nullable=True)
closed_by_user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Platform Billing (SaaS billing tracking)
platform_billing_plan = Column(Text, nullable=True)
is_billable = Column(Boolean, default=True, nullable=False)
# Financial Tracking (JSONB)
# Example: {"payroll": 500000, "equipment": 300000, "transport": 100000, "materials": 150000}
budget = Column(JSONB, default={}, nullable=False)
# Inventory Requirements (JSONB Dictionary) - Defines allowed inventory types for this project
# Key: inventory code, Value: inventory requirement object
# Example: {
# "ONT-ZTE-F670L": {
# "code": "ONT-ZTE-F670L",
# "name": "ZTE F670L ONT Device",
# "description": "Fiber optic network terminal for FTTH installations",
# "usage_type": "installed",
# "unit": "pieces",
# "requires_serial_number": true,
# "category": "Equipment",
# "include_in_completion": true,
# "completion_field_label": "ONT Serial Number",
# "completion_required": true
# },
# "CABLE-FIBER-SM": {
# "code": "CABLE-FIBER-SM",
# "name": "Single Mode Fiber Cable",
# "description": "Outdoor fiber optic cable",
# "usage_type": "consumed",
# "unit": "meters",
# "requires_serial_number": false,
# "category": "Cable"
# }
# }
inventory_requirements = Column(JSONB, default={}, nullable=False)
# Photo Requirements (JSONB Array)
# Example: [{"type": "before_installation", "required": true, "min": 1, "max": 3}, ...]
photo_requirements = Column(JSONB, default=[], nullable=False)
# Activation Requirements (JSONB Array) - Dynamic form fields for subscription activation
# Example: [{"field": "ont_serial_number", "label": "ONT Serial Number", "type": "text", "required": true}, ...]
activation_requirements = Column(JSONB, default=[], nullable=False)
# Invoice Pricing Rules (JSONB) - Flexible pricing for automatic invoice calculation
# Example: {"pricing_model": "tiered", "tiers": [...], "tax_rate": 16}
invoice_pricing_rules = Column(JSONB, default=None, nullable=True)
# Total Tickets Invoiced (for cumulative pricing calculations)
total_tickets_invoiced = Column(Integer, default=0, nullable=False)
# Additional Metadata (flexible JSONB for future needs)
additional_metadata = Column(JSONB, default={}, nullable=False)
# Relationships
client = relationship("Client", foreign_keys=[client_id], lazy='joined')
contractor = relationship("Contractor", foreign_keys=[contractor_id], lazy='joined')
primary_manager = relationship("User", foreign_keys=[primary_manager_id])
closed_by_user = relationship("User", foreign_keys=[closed_by_user_id])
# Child relationships (cascade delete)
regions = relationship("ProjectRegion", back_populates="project", cascade="all, delete-orphan")
roles = relationship("ProjectRole", back_populates="project", cascade="all, delete-orphan")
team = relationship("ProjectTeam", back_populates="project", cascade="all, delete-orphan")
subcontractors = relationship("ProjectSubcontractor", back_populates="project", cascade="all, delete-orphan")
timesheets = relationship("Timesheet", back_populates="project", lazy="dynamic")
payrolls = relationship("UserPayroll", back_populates="project", lazy="dynamic") # Worker payroll records
tasks = relationship("Task", back_populates="project", lazy="dynamic") # For infrastructure projects
inventory = relationship("ProjectInventory", back_populates="project", lazy="dynamic") # Equipment & materials
finance_transactions = relationship("ProjectFinance", back_populates="project", lazy="dynamic") # Financial transactions
sales_orders = relationship("SalesOrder", back_populates="project", lazy="dynamic") # Sales orders for installations
tickets = relationship("Ticket", back_populates="project", lazy="dynamic") # Work orders from sales/incidents/tasks
# Table constraints
__table_args__ = (
CheckConstraint(
'(planned_end_date IS NULL OR planned_start_date IS NULL OR planned_end_date >= planned_start_date)',
name='chk_project_planned_dates'
),
CheckConstraint(
'(actual_end_date IS NULL OR actual_start_date IS NULL OR actual_end_date >= actual_start_date)',
name='chk_project_actual_dates'
),
)
def __repr__(self):
return f"<Project(title='{self.title}', status='{self.status}')>"
# Validation Methods
def can_activate(self) -> bool:
"""Check if project has minimum requirements to activate"""
return (
self.status == ProjectStatus.PLANNING.value and
self.client_id is not None and
self.contractor_id is not None and
self.title is not None
)
def can_close(self) -> bool:
"""Check if project can be closed (business rules)"""
return (
self.status in [ProjectStatus.ACTIVE.value, ProjectStatus.ON_HOLD.value, ProjectStatus.COMPLETED.value] and
not self.is_closed
)
def is_active_period(self) -> bool:
"""Check if current date is within project period"""
from datetime import date
today = date.today()
if self.actual_start_date and self.actual_end_date:
return self.actual_start_date <= today <= self.actual_end_date
elif self.planned_start_date and self.planned_end_date:
return self.planned_start_date <= today <= self.planned_end_date
return False
@property
def is_overdue(self) -> bool:
"""Check if project is overdue"""
from datetime import date
today = date.today()
if self.is_closed:
return False
end_date = self.actual_end_date or self.planned_end_date
return end_date is not None and end_date < today
@property
def duration_days(self) -> Optional[int]:
"""Calculate project duration in days"""
start = self.actual_start_date or self.planned_start_date
end = self.actual_end_date or self.planned_end_date
if start and end:
return (end - start).days
return None
class ProjectRegion(BaseModel):
"""
ProjectRegion model - Geographic organization within a project
Maps to 'project_regions' table in docs/schema/schema.sql
Purpose: Inventory distribution hubs and team organization
Flow: Main office → Regional hubs → Field agents collect → Customer sites
Examples: "Nairobi West", "Mombasa Central", "Kisumu North"
"""
__tablename__ = "project_regions"
# Relationships
project_id = Column(UUID(as_uuid=True), ForeignKey('projects.id', ondelete='CASCADE'), nullable=False)
# Region Details
region_name = Column(Text, nullable=False) # e.g., "Nairobi West Hub", "Kimbo"
region_code = Column(Text, nullable=True) # Short code: "NRB-W"
description = Column(Text, nullable=True)
# Location Hierarchy (Country → Region/County → City/Sub-county → Address → Coordinates)
country = Column(Text, default='Kenya', nullable=False)
region = Column(Text, nullable=True) # Administrative region/county (e.g., "Nairobi", "Kiambu")
city = Column(Text, nullable=True) # Sub-county or city (e.g., "Embakasi East", "Ruiru")
address_line1 = Column(Text, nullable=True) # Physical address of regional hub
address_line2 = Column(Text, nullable=True)
maps_link = Column(Text, nullable=True) # Google Maps share link
latitude = Column(Numeric(precision=10, scale=7), nullable=True)
longitude = Column(Numeric(precision=10, scale=7), nullable=True)
# Regional Manager
manager_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Hub Contact Persons (for public hub tracking access)
# Array of authorized persons who can view hub inventory without user accounts
# Structure: [{"name": "John", "phone": "+254...", "email": "...", "role": "Hub Coordinator"}]
hub_contact_persons = Column(JSONB, default=[], nullable=False)
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Metadata
notes = Column(Text, nullable=True)
additional_metadata = Column(JSONB, default={}, nullable=False)
# Relationships
project = relationship("Project", back_populates="regions")
manager = relationship("User", foreign_keys=[manager_id])
inventory_distributions = relationship("ProjectInventoryDistribution", back_populates="region", lazy="dynamic")
def __repr__(self):
return f"<ProjectRegion(name='{self.region_name}', project_id='{self.project_id}')>"
class ProjectRole(BaseModel):
"""
ProjectRole model - Defines roles and compensation within a project
Simplified global compensation structure with 4 types:
- FIXED_RATE: Time-based pay (hourly, daily, weekly, monthly)
- PER_UNIT: Work-based pay (per-ticket, per-job)
- COMMISSION: Percentage-based pay (sales commission)
- FIXED_PLUS_COMMISSION: Hybrid (base + commission)
Examples:
- Kenya daily worker: FIXED_RATE, base_rate=1000, rate_period=DAY
- USA hourly worker: FIXED_RATE, base_rate=25, rate_period=HOUR
- Per-ticket tech: PER_UNIT, per_unit_rate=500
- Sales agent: COMMISSION, commission_percentage=10
- Hybrid: FIXED_PLUS_COMMISSION, base_rate=500, rate_period=DAY, commission_percentage=5
"""
__tablename__ = "project_roles"
# Relationships
project_id = Column(UUID(as_uuid=True), ForeignKey('projects.id', ondelete='CASCADE'), nullable=False)
# Role Definition
role_name = Column(Text, nullable=False) # e.g., 'Technician', 'Supervisor'
description = Column(Text, nullable=True)
# Compensation Structure
compensation_type = Column(String(50), nullable=False) # CompensationType enum in DB
# For FIXED_RATE and FIXED_PLUS_COMMISSION
base_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Base rate amount
rate_period = Column(String(10), nullable=True) # RatePeriod enum: HOUR, DAY, WEEK, MONTH
# For PER_UNIT
per_unit_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Per-ticket/job rate
# For COMMISSION and FIXED_PLUS_COMMISSION
commission_percentage = Column(Numeric(precision=5, scale=2), nullable=True) # Commission %
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
project = relationship("Project", back_populates="roles")
# Table constraints
__table_args__ = (
# FIXED_RATE requires base_rate and rate_period
CheckConstraint(
"compensation_type != 'FIXED_RATE' OR (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL)",
name='chk_fixed_rate_fields'
),
# PER_UNIT requires per_unit_rate
CheckConstraint(
"compensation_type != 'PER_UNIT' OR (per_unit_rate IS NOT NULL AND per_unit_rate >= 0)",
name='chk_per_unit_fields'
),
# COMMISSION requires commission_percentage
CheckConstraint(
"compensation_type != 'COMMISSION' OR (commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)",
name='chk_commission_fields'
),
# FIXED_PLUS_COMMISSION requires base_rate, rate_period, and commission_percentage
CheckConstraint(
"compensation_type != 'FIXED_PLUS_COMMISSION' OR (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL AND commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)",
name='chk_fixed_plus_commission_fields'
),
)
def __repr__(self):
return f"<ProjectRole(name='{self.role_name}', type='{self.compensation_type}')>"
class ProjectSubcontractor(BaseModel):
"""
ProjectSubcontractor model - Subcontracting relationships within a project
Maps to 'project_subcontractors' table in docs/schema/schema.sql
Purpose: Track additional contractors hired by main contractor
A project has one main contractor (projects.contractor_id) and can have multiple subcontractors
"""
__tablename__ = "project_subcontractors"
# Relationships
project_id = Column(UUID(as_uuid=True), ForeignKey('projects.id', ondelete='CASCADE'), nullable=False)
subcontractor_id = Column(UUID(as_uuid=True), ForeignKey('contractors.id', ondelete='RESTRICT'), nullable=False)
# Scope of Work
scope_description = Column(Text, nullable=True) # What work is this subcontractor responsible for?
# Regional Assignment (optional - NULL means project-wide)
project_region_id = Column(UUID(as_uuid=True), ForeignKey('project_regions.id', ondelete='SET NULL'), nullable=True)
# Contract Details
contract_start_date = Column(Date, nullable=True)
contract_end_date = Column(Date, nullable=True)
contract_value = Column(Numeric(precision=12, scale=2), nullable=True)
currency = Column(Text, default='KES', nullable=False)
# Status
is_active = Column(Boolean, default=True, nullable=False)
activated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
deactivated_at = Column(DateTime, nullable=True)
deactivated_by_user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
deactivation_reason = Column(Text, nullable=True)
# Metadata
notes = Column(Text, nullable=True)
additional_metadata = Column(JSONB, default={}, nullable=False)
# Relationships
project = relationship("Project", back_populates="subcontractors")
subcontractor = relationship("Contractor", foreign_keys=[subcontractor_id])
region = relationship("ProjectRegion", foreign_keys=[project_region_id])
deactivated_by_user = relationship("User", foreign_keys=[deactivated_by_user_id])
def __repr__(self):
return f"<ProjectSubcontractor(project_id='{self.project_id}', subcontractor_id='{self.subcontractor_id}')>"
|