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