kamau1's picture
feat: add flexible invoice pricing system with tiered, flat rate, time-based, and custom pricing models
f18d2f1
"""
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}')>"