61 min readIntelligence & insights

Implementation Guide: Analyze project cost-to-complete vs. budget and flag margin erosion early

Step-by-step implementation guide for deploying AI to analyze project cost-to-complete vs. budget and flag margin erosion early for Construction & Contractors clients.

Hardware Procurement

Business-Class Firewall/Router

FortinetFortiGate 60F (FG-60F)Qty: 1

$500–$600 MSP cost / $800–$950 suggested resale

Provides secure, reliable internet gateway with VPN capabilities for remote field PM access to dashboards, content filtering, and network segmentation to isolate financial data traffic. Required for any client without an existing enterprise-grade firewall.

Uninterruptible Power Supply

APC by Schneider ElectricAPC Smart-UPS 1500VA (SMT1500C)Qty: 1

$450–$550 MSP cost / $650–$800 suggested resale

Battery backup for the client's primary network equipment (firewall, switch, ISP modem) to ensure continuous connectivity to cloud services during power fluctuations common on construction office sites.

Project Manager Laptops

Project Manager Laptops

DellDell Latitude 5550 (i7, 16GB RAM, 512GB SSD)Qty: 3

$950–$1,100 MSP cost per unit / $1,300–$1,500 suggested resale per unit

Standard business laptops for project managers and the controller who will be primary dashboard users. Must support modern browser for Power BI web access and Microsoft 365 apps. Quantity adjustable based on client headcount.

Conference Room Display

Samsung QM55C 55-inch 4K Smart Signage Display

SamsungQM55CQty: 1

$700–$900 MSP cost / $1,100–$1,400 suggested resale

Wall-mounted display in the client's main conference or war room for always-on project portfolio dashboard visibility during PM meetings and executive reviews. Power BI dashboards display in kiosk mode via dedicated HDMI mini-PC or built-in browser.

Dashboard Kiosk Mini PC

Dashboard Kiosk Mini PC

IntelIntel NUC 13 Pro (RNUC13ANHI50001)Qty: 1

$350–$450 MSP cost / $550–$650 suggested resale

Dedicated mini PC connected to the conference room display running Power BI in kiosk/full-screen browser mode. Configured for auto-start, auto-login, and auto-launch of the dashboard URL.

Software Procurement

Microsoft 365 Business Premium

Microsoftper-seat SaaS (monthly via CSP)Qty: typical 8–15 users

$22/user/month MSP cost via CSP / $30–$35/user/month suggested resale; typical 8–15 users = $176–$330/month MSP cost

Foundation platform providing Exchange Online, Teams (for alert delivery), SharePoint (document storage), and qualifying base license for Copilot add-on. Includes Intune for endpoint management and Azure AD P1 for conditional access policies on financial data.

Power BI Pro

Microsoftper-seat SaaS (monthly via CSP)Qty: 5–8 users

$14/user/month; typically 5–8 dashboard consumers = $70–$112/month. Bundle into analytics package at $22–$28/user/month resale

Core BI visualization platform for all cost-to-complete dashboards, margin erosion trend charts, WIP schedule reports, and automated alert rule configuration via Power Automate integration.

Azure Subscription (Pay-As-You-Go)

Microsoftusage-based (via CSP)

Estimated $150–$400/month total Azure spend: Azure SQL Database (~$50–$150/month for S2 tier), Azure Functions (~$10–$30/month), Azure OpenAI Service (~$30–$100/month), Storage (~$5–$15/month). Mark up 20–25% for resale.

Cloud infrastructure hosting the data warehouse (Azure SQL), ETL orchestration (Azure Functions), AI inference (Azure OpenAI), and blob storage for historical data exports and backups.

Azure OpenAI Service

Microsoftusage-based APIQty: 50–200 analysis runs/month

GPT-5.4: $0.005/1K input tokens, $0.015/1K output tokens; GPT-5.4 mini: $0.00015/1K input, $0.0006/1K output. Estimated $30–$100/month for typical contractor workload.

Powers the AI narrative generation engine that produces plain-English margin erosion explanations, risk assessments, and recommended actions for each project. Also used for anomaly detection prompts against cost data patterns.

$0 additional (included); Premium connectors require Power Automate Premium at $15/user/month if needed

Workflow automation engine for margin erosion alert delivery (email, Teams, SMS via Twilio connector), scheduled dashboard refresh triggers, and weekly executive summary distribution.

Agave API

Agaveper-connection SaaSQty: 1–2 connections

Custom pricing by quote; typically $200–$500/month per active connection. Budget $300–$600/month for 1–2 connections.

Unified construction software API that provides a single integration point to extract job cost, budget, commitment, and change order data from Procore, Sage 100/300/Intacct, QuickBooks, FOUNDATION, Viewpoint, and 40+ other construction platforms. Eliminates need to build individual API integrations.

Microsoft 365 Copilot (Optional Enhancement)

Microsoftper-seat SaaS add-onQty: 2–3 power users (controller, CFO, lead PM)

$18–$30/user/month depending on plan; $54–$90/month total. Resale at $35–$40/user/month.

Optional AI assistant enabling natural-language queries against cost data directly in Excel and Power BI. Allows the controller to ask questions like 'Which projects have the highest risk of margin erosion this quarter?' without navigating dashboards.

Twilio SendGrid (or Twilio SMS)

Twiliousage-based API

SendGrid free tier: 100 emails/day; Essentials: $19.95/month for 50K emails. Twilio SMS: $0.0079/message. Budget $20–$50/month.

SMS and email alert delivery for critical margin erosion notifications to project managers in the field who may not be monitoring Teams or email continuously.

Prerequisites

  • Active construction accounting system with job costing enabled and at least 12 months of historical project data (24+ months preferred for AI model training). Supported systems: QuickBooks Desktop Enterprise Contractor Edition, QuickBooks Online, Sage 100 Contractor, Sage 300 CRE, Sage Intacct, FOUNDATION Software, Viewpoint Vista/Spectrum, or Jonas Premier.
  • Active construction project management platform with budget and cost tracking modules in use. Supported: Procore (Budget module), Buildertrend (Advanced or Complete tier), Autodesk Build, or equivalent.
  • Standardized cost code structure consistently applied across all active and historical projects. CSI MasterFormat or the client's custom cost code taxonomy must be documented and consistently used.
  • Data export capability from the accounting system: API access (preferred), ODBC connection, or reliable CSV/Excel export process. Verify with the client's accounting software vendor or IT contact.
  • Client must have a designated 'data champion' — typically the construction controller, CFO, or senior PM — who will own data quality, validate AI outputs, and provide business context during implementation.
  • Microsoft 365 Business Premium licenses (or equivalent E3/E5) for all dashboard users and administrators. If not already in place, the MSP should migrate the client to M365 as a prerequisite engagement.
  • Minimum 50 Mbps download / 10 Mbps upload internet connectivity at the client's main office. 100/100 Mbps symmetric recommended for cloud-first deployment.
  • Business-class firewall with VPN or ZTNA capability for secure remote access to dashboards from job sites.
  • Client must have a documented process for project managers to update cost-to-complete estimates at least monthly (weekly preferred). If this process does not exist, the MSP must help establish it in Phase 1.
  • Budget loaded into the accounting/PM system before work begins on each project. Retroactive budget entry for all active projects must be completed before go-live.

Installation Steps

...

Step 1: Discovery & Data Audit

Conduct a thorough audit of the client's existing software ecosystem, data quality, and business processes. This is the most critical phase — poor data quality is the #1 cause of failed construction analytics implementations. Meet with the controller/CFO and 2-3 project managers to understand their current cost tracking workflow, WIP reporting process, and pain points. Document all software systems, versions, data export capabilities, cost code structures, and integration points. Assess historical data completeness and consistency.

Note

Deliverable: Data Audit Report documenting all source systems, data quality scores (completeness, consistency, timeliness), cost code mapping, integration approach recommendation, and risk register. Allocate 16–32 hours for this phase. Do NOT proceed to Phase 2 until the client signs off on the Data Audit Report.

Step 2: Provision Azure Environment

Create and configure the Azure resource group that will host the data warehouse, ETL functions, and AI services. Use the MSP's CSP tenant or the client's own Azure AD tenant depending on the MSP's operating model. Create the following resources: Azure SQL Database (Standard S2 tier), Azure Functions App (Consumption plan), Azure OpenAI Service instance, Azure Blob Storage account, and Application Insights for monitoring.

bash
az login
az group create --name rg-construction-analytics --location eastus2
az sql server create --name sql-construction-analytics --resource-group rg-construction-analytics --location eastus2 --admin-user sqladmin --admin-password '<STRONG_PASSWORD_HERE>'
az sql db create --resource-group rg-construction-analytics --server sql-construction-analytics --name ConstructionCostDB --service-objective S2 --backup-storage-redundancy Local
az storage account create --name stconstructiondata --resource-group rg-construction-analytics --location eastus2 --sku Standard_LRS --kind StorageV2
az storage container create --name raw-imports --account-name stconstructiondata
az storage container create --name processed-data --account-name stconstructiondata
az functionapp create --resource-group rg-construction-analytics --consumption-plan-location eastus2 --runtime python --runtime-version 3.11 --functions-version 4 --name func-construction-etl --storage-account stconstructiondata
az cognitiveservices account create --name openai-construction --resource-group rg-construction-analytics --kind OpenAI --sku S0 --location eastus2
az cognitiveservices account deployment create --name openai-construction --resource-group rg-construction-analytics --deployment-name gpt4o-analysis --model-name gpt-5.4 --model-version 2024-08-06 --model-format OpenAI --sku-capacity 10 --sku-name Standard
az monitor app-insights component create --app insights-construction --location eastus2 --resource-group rg-construction-analytics
Note

Use a strong, unique password for the SQL admin account and store it in Azure Key Vault or your MSP's password manager (e.g., IT Glue, Hudu). Enable Azure SQL firewall rules to allow only the Azure Functions app and your MSP's management IPs. Enable Transparent Data Encryption (TDE) on the SQL database (enabled by default). The S2 tier provides 50 DTUs which is sufficient for most SMB contractors; monitor and scale to S3 if query performance degrades.

Step 3: Design and Deploy the Data Warehouse Schema

Create the Azure SQL Database schema to serve as the central data warehouse for all construction cost data. The schema follows a star-schema design optimized for Power BI DirectQuery and Import modes. Core tables include: dim_projects, dim_cost_codes, dim_vendors, dim_employees, fact_budget, fact_actual_costs, fact_commitments, fact_change_orders, fact_cost_to_complete, and fact_margin_snapshots. This schema normalizes data from multiple source systems into a unified analytical model.

1
Connect to Azure SQL Database using Azure Data Studio or sqlcmd
Connect to Azure SQL Database via sqlcmd
bash
sqlcmd -S sql-construction-analytics.database.windows.net -d ConstructionCostDB -U sqladmin -P '<PASSWORD>'
Full star-schema DDL for the ConstructionCostDB data warehouse
sql
CREATE SCHEMA construction;
GO

CREATE TABLE construction.dim_projects (
    project_id INT IDENTITY(1,1) PRIMARY KEY,
    source_system_id NVARCHAR(50) NOT NULL,
    source_system NVARCHAR(50) NOT NULL, -- 'procore', 'sage', 'quickbooks', etc.
    project_name NVARCHAR(255) NOT NULL,
    project_number NVARCHAR(50),
    client_name NVARCHAR(255),
    project_manager NVARCHAR(255),
    contract_amount DECIMAL(18,2),
    original_budget DECIMAL(18,2),
    revised_budget DECIMAL(18,2),
    start_date DATE,
    estimated_completion_date DATE,
    actual_completion_date DATE,
    project_status NVARCHAR(50) DEFAULT 'Active', -- Active, Complete, On Hold, Cancelled
    project_type NVARCHAR(100), -- Residential, Commercial, Industrial, Infrastructure
    bonded BIT DEFAULT 0,
    prevailing_wage BIT DEFAULT 0,
    created_at DATETIME2 DEFAULT GETUTCDATE(),
    updated_at DATETIME2 DEFAULT GETUTCDATE()
);

CREATE TABLE construction.dim_cost_codes (
    cost_code_id INT IDENTITY(1,1) PRIMARY KEY,
    cost_code NVARCHAR(50) NOT NULL,
    cost_code_description NVARCHAR(255),
    cost_type NVARCHAR(50), -- Labor, Material, Subcontractor, Equipment, Other
    csi_division NVARCHAR(10), -- CSI MasterFormat division if applicable
    parent_cost_code NVARCHAR(50),
    is_active BIT DEFAULT 1
);

CREATE TABLE construction.dim_vendors (
    vendor_id INT IDENTITY(1,1) PRIMARY KEY,
    source_system_id NVARCHAR(50),
    vendor_name NVARCHAR(255) NOT NULL,
    vendor_type NVARCHAR(50), -- Subcontractor, Supplier, Equipment Rental
    is_active BIT DEFAULT 1
);

CREATE TABLE construction.fact_budget (
    budget_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    cost_code_id INT FOREIGN KEY REFERENCES construction.dim_cost_codes(cost_code_id),
    original_budget_amount DECIMAL(18,2) NOT NULL,
    approved_changes DECIMAL(18,2) DEFAULT 0,
    revised_budget_amount AS (original_budget_amount + approved_changes),
    budget_date DATE NOT NULL,
    snapshot_date DATE NOT NULL DEFAULT CAST(GETUTCDATE() AS DATE)
);

CREATE TABLE construction.fact_actual_costs (
    actual_cost_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    cost_code_id INT FOREIGN KEY REFERENCES construction.dim_cost_codes(cost_code_id),
    vendor_id INT FOREIGN KEY REFERENCES construction.dim_vendors(vendor_id),
    transaction_date DATE NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    description NVARCHAR(500),
    source_document NVARCHAR(100), -- PO number, invoice number, timecard ID
    cost_type NVARCHAR(50), -- Labor, Material, Sub, Equipment, Other
    imported_at DATETIME2 DEFAULT GETUTCDATE()
);

CREATE TABLE construction.fact_commitments (
    commitment_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    cost_code_id INT FOREIGN KEY REFERENCES construction.dim_cost_codes(cost_code_id),
    vendor_id INT FOREIGN KEY REFERENCES construction.dim_vendors(vendor_id),
    commitment_type NVARCHAR(50), -- Purchase Order, Subcontract, Rental Agreement
    original_amount DECIMAL(18,2) NOT NULL,
    approved_changes DECIMAL(18,2) DEFAULT 0,
    revised_amount AS (original_amount + approved_changes),
    invoiced_to_date DECIMAL(18,2) DEFAULT 0,
    remaining_to_invoice AS (original_amount + approved_changes - invoiced_to_date),
    commitment_date DATE,
    status NVARCHAR(50) DEFAULT 'Open'
);

CREATE TABLE construction.fact_change_orders (
    change_order_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    change_order_number NVARCHAR(50),
    description NVARCHAR(500),
    revenue_amount DECIMAL(18,2) DEFAULT 0,
    cost_amount DECIMAL(18,2) DEFAULT 0,
    margin_impact AS (revenue_amount - cost_amount),
    status NVARCHAR(50), -- Pending, Approved, Rejected
    submitted_date DATE,
    approved_date DATE
);

CREATE TABLE construction.fact_cost_to_complete (
    ctc_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    cost_code_id INT FOREIGN KEY REFERENCES construction.dim_cost_codes(cost_code_id),
    estimate_date DATE NOT NULL,
    cost_to_date DECIMAL(18,2) NOT NULL,
    committed_not_invoiced DECIMAL(18,2) DEFAULT 0,
    estimated_cost_to_complete DECIMAL(18,2) NOT NULL,
    estimate_at_completion AS (cost_to_date + committed_not_invoiced + estimated_cost_to_complete),
    percent_complete DECIMAL(5,2),
    entered_by NVARCHAR(255),
    notes NVARCHAR(1000),
    snapshot_date DATE NOT NULL DEFAULT CAST(GETUTCDATE() AS DATE)
);

CREATE TABLE construction.fact_margin_snapshots (
    snapshot_id INT IDENTITY(1,1) PRIMARY KEY,
    project_id INT FOREIGN KEY REFERENCES construction.dim_projects(project_id),
    snapshot_date DATE NOT NULL,
    contract_amount DECIMAL(18,2),
    revised_budget DECIMAL(18,2),
    cost_to_date DECIMAL(18,2),
    committed_costs DECIMAL(18,2),
    estimated_cost_to_complete DECIMAL(18,2),
    estimate_at_completion DECIMAL(18,2),
    projected_margin_amount AS (contract_amount - estimate_at_completion),
    projected_margin_pct AS (CASE WHEN contract_amount > 0 THEN (contract_amount - estimate_at_completion) / contract_amount * 100 ELSE 0 END),
    original_margin_pct DECIMAL(5,2),
    margin_erosion_pct AS (CASE WHEN contract_amount > 0 THEN ((contract_amount - estimate_at_completion) / contract_amount * 100) ELSE 0 END - ISNULL(original_margin_pct, 0)),
    earned_value DECIMAL(18,2),
    schedule_variance DECIMAL(18,2),
    cost_variance DECIMAL(18,2),
    ai_risk_score DECIMAL(5,2), -- 0-100 risk score from AI model
    ai_narrative NVARCHAR(MAX), -- AI-generated plain-English summary
    alert_triggered BIT DEFAULT 0
);

-- Create indexes for common query patterns
CREATE INDEX IX_fact_actual_costs_project ON construction.fact_actual_costs(project_id, transaction_date);
CREATE INDEX IX_fact_commitments_project ON construction.fact_commitments(project_id);
CREATE INDEX IX_fact_ctc_project_date ON construction.fact_cost_to_complete(project_id, estimate_date);
CREATE INDEX IX_fact_margin_snapshot ON construction.fact_margin_snapshots(project_id, snapshot_date);
Note

This schema is designed to be source-system agnostic — the ETL layer (Step 5) handles mapping from Procore, Sage, QuickBooks, etc. into this unified schema. The computed columns (revised_budget_amount, estimate_at_completion, projected_margin_amount, etc.) ensure consistent calculations regardless of which system the data originates from. Save this SQL script in the MSP's documentation repository for reuse across clients.

Step 4: Configure Agave API Integration

Set up Agave API as the unified middleware layer to extract data from the client's construction management and accounting systems. Agave provides a single API that normalizes data from 40+ construction platforms. Create an Agave account, configure the client's source system connections, and test data extraction. If the client uses Procore, you can alternatively use Procore's native API directly — but Agave is recommended when the client uses multiple systems or a system without a robust API.

1
Sign up at https://www.agaveapi.com/ and obtain API credentials
2
Store credentials in Azure Key Vault
Create Azure Key Vault and store Agave credentials
bash
az keyvault create --name kv-construction --resource-group rg-construction-analytics --location eastus2
az keyvault secret set --vault-name kv-construction --name AgaveApiKey --value '<AGAVE_API_KEY>'
az keyvault secret set --vault-name kv-construction --name AgaveClientSecret --value '<AGAVE_CLIENT_SECRET>'
1
Test Agave connection (Python example)
Test Agave API connection and list linked accounts
python
pip install requests
python3 -c "
import requests
headers = {'API-Key': '<AGAVE_API_KEY>', 'Client-Secret': '<AGAVE_CLIENT_SECRET>'}
# List linked accounts
resp = requests.get('https://api.agaveapi.com/linked-accounts', headers=headers)
print(resp.json())
"
Note

Agave requires the client to authorize access to their construction software via an OAuth-like link flow. Schedule a 30-minute call with the client's controller to walk through the authorization process. If the client uses QuickBooks Desktop (not Online), Agave may require a local connector agent installed on the QuickBooks host machine. For clients using only Procore + QuickBooks Online, direct API integration without Agave is also viable (see Alternative approaches). Budget 4–8 hours for integration configuration and testing.

Step 5: Build ETL Pipeline with Azure Functions

Deploy Python-based Azure Functions that run on a scheduled timer to extract data from the client's systems via Agave API (or direct APIs), transform it into the unified data warehouse schema, and load it into Azure SQL Database. Create four functions: (1) sync_projects — pulls project master data, (2) sync_costs — pulls actual cost transactions, (3) sync_commitments — pulls POs and subcontracts, (4) calculate_margins — computes margin snapshots and triggers AI analysis. Schedule the cost sync to run every 4 hours during business hours and the margin calculation to run nightly.

1
Initialize Azure Functions project
2
Create function files
3
Install dependencies
4
Deploy to Azure
Initialize Azure Functions project, scaffold timer-triggered functions, install dependencies, and deploy
bash
func init construction-etl --python
cd construction-etl

func new --name sync_projects --template 'Timer trigger'
func new --name sync_costs --template 'Timer trigger'
func new --name sync_commitments --template 'Timer trigger'
func new --name calculate_margins --template 'Timer trigger'

pip install pyodbc requests openai azure-identity azure-keyvault-secrets
pip freeze > requirements.txt

func azure functionapp publish func-construction-etl
Note

The full Python code for each function is provided in the custom_ai_components section. Timer schedules: sync_projects runs daily at 6 AM ET (0 10 * * * in UTC), sync_costs runs every 4 hours during business hours (0 12,16,20,0 * * *), sync_commitments runs daily at 7 AM ET (0 11 * * *), calculate_margins runs nightly at 11 PM ET (0 3 * * *). All times in UTC for Azure. Ensure the Azure Function has network access to Azure SQL (configure SQL firewall to allow Azure services). Store all API keys and connection strings in Azure Key Vault, not in code.

Step 6: Configure Azure OpenAI for Margin Analysis

Deploy and configure the GPT-5.4 model in Azure OpenAI Service for generating plain-English margin erosion narratives, risk scoring, and recommended corrective actions. Create a system prompt template tailored to construction cost analysis that the calculate_margins function will call for each project flagged with margin erosion.

Verify deployment, store secrets in Key Vault, and test the Azure OpenAI endpoint with a sample construction cost analysis prompt
bash
# Verify OpenAI deployment is ready
az cognitiveservices account deployment list --name openai-construction --resource-group rg-construction-analytics -o table

# Store the OpenAI endpoint and key in Key Vault
az keyvault secret set --vault-name kv-construction --name OpenAIEndpoint --value 'https://openai-construction.openai.azure.com/'
az keyvault secret set --vault-name kv-construction --name OpenAIKey --value '<OPENAI_KEY>'

# Test the deployment with a sample construction cost analysis prompt
python3 -c "
from openai import AzureOpenAI
client = AzureOpenAI(
    azure_endpoint='https://openai-construction.openai.azure.com/',
    api_key='<OPENAI_KEY>',
    api_version='2024-06-01'
)
response = client.chat.completions.create(
    model='gpt4o-analysis',
    messages=[
        {'role': 'system', 'content': 'You are a construction cost analyst assistant.'},
        {'role': 'user', 'content': 'Project ABC has original budget 500000, cost to date 350000 at 60% complete, committed costs 100000, estimated cost to complete 120000. Analyze margin risk.'}
    ],
    temperature=0.3
)
print(response.choices[0].message.content)
"
Note

Use temperature=0.3 for consistent, factual analysis outputs. The system prompt (provided in full in custom_ai_components) is critical — it constrains the model to construction-specific analysis and prevents hallucination of financial figures. Always pass actual numbers from the database; never ask the model to estimate or calculate financial figures itself. The model provides narrative interpretation only. For cost control, set a monthly spending limit on the Azure OpenAI resource and monitor via Application Insights.

Step 7: Build Power BI Dashboard Suite

Create the Power BI report file (.pbix) containing four dashboard pages: (1) Portfolio Overview — all active projects with margin health indicators, (2) Project Deep Dive — detailed cost-to-complete vs. budget for a selected project, (3) Margin Erosion Trend — historical margin snapshots showing erosion trajectory, (4) WIP Schedule — automated Work-in-Progress report for surety/bonding review. Connect Power BI to Azure SQL Database using DirectQuery for real-time data. Publish to Power BI Service workspace and configure scheduled refresh.

Power BI connection string for Azure SQL
text
# Power BI connection string for Azure SQL
# Server: sql-construction-analytics.database.windows.net
# Database: ConstructionCostDB
# Authentication: Azure Active Directory or SQL Authentication
1
Go to app.powerbi.com > Settings > Manage gateways
2
Add data source: Azure SQL Database
3
Enter credentials and test connection
1
Configure scheduled refresh (8x daily during business hours): Settings > Datasets > Scheduled refresh > Enable
2
Set refresh frequency: 8 times per day
3
Set time zone: Client's local time zone
1
Create Power BI App for easy distribution: Workspace > Create app > Configure navigation > Publish
Note

The detailed Power BI DAX measures and page layouts are specified in the custom_ai_components section. Use DirectQuery mode for the margin_snapshots and actual_costs tables (real-time), but Import mode for dimension tables (projects, cost_codes, vendors) which change infrequently. Apply Row-Level Security (RLS) so project managers only see their own projects while the controller and CFO see all projects. Use the client's branding (logo, colors) on the dashboard. Test all visualizations with the client's actual data before publishing.

Step 8: Configure Automated Alerts via Power Automate

Create Power Automate flows that trigger when margin erosion exceeds configured thresholds. Three alert levels: (1) WATCH — margin erosion of 2–5 percentage points from original estimate, sends Teams notification to PM, (2) WARNING — margin erosion of 5–10 percentage points, sends email to PM + controller with AI narrative, (3) CRITICAL — margin erosion exceeds 10 percentage points OR projected loss, sends email + SMS to PM + controller + CFO with AI narrative and recommended actions. Also create a weekly executive summary flow.

1
Flow 1: Margin Erosion Alert — navigate to https://make.powerautomate.com to create via the web UI (flow definitions can be exported as JSON for backup and replication)
2
Configure Trigger: Scheduled (runs after calculate_margins function, e.g., 1:00 AM ET daily)
3
Action 1: Query Azure SQL for projects where alert_triggered = 1 and snapshot_date = today
4
Action 2: For each project, determine alert level based on margin_erosion_pct
5
Action 3: Compose Teams Adaptive Card with project details and AI narrative
6
Action 4: Send Teams message / email / SMS based on alert level
7
Flow 2: Weekly Executive Summary — configure Trigger: Recurrence (every Monday at 8:00 AM)
8
Action 1: Query Azure SQL for all active project margin snapshots
9
Action 2: Call Azure OpenAI to generate portfolio-level executive summary
10
Action 3: Send formatted email to CFO and executive team
Note

Threshold values should be configurable — store them in an Azure SQL configuration table or SharePoint list that the client can update. Default thresholds: WATCH = 2% erosion, WARNING = 5% erosion, CRITICAL = 10% erosion or projected loss. For SMS alerts, integrate Twilio via a custom connector in Power Automate or use the built-in SMS connector if available. Always include a link to the Power BI dashboard in every alert so the recipient can drill into details immediately. Test all alert paths during UAT with simulated data.

Step 9: Historical Data Backfill and AI Calibration

Import 12–24 months of historical project data from the client's accounting system into the data warehouse. Run the margin calculation function against historical data to establish baseline patterns. Use completed projects to calibrate alert thresholds — analyze what margin erosion patterns preceded actual losses on past projects. Adjust the AI system prompt and alert thresholds based on the client's specific historical patterns.

1
Export historical data from accounting system — For QuickBooks: Reports > Jobs, Time & Mileage > Job Profitability Detail > Export to Excel; For Sage: Reports > Job Cost > Job Cost Detail > Export to CSV; For Procore: Admin > Company > Data Export > Cost Codes, Budgets, Direct Costs
2
Upload historical exports to Azure Blob Storage
3
Run the historical data import script (provided in custom_ai_components)
4
Run margin calculations on historical data
5
Analyze historical accuracy
Upload historical exports to Azure Blob Storage
bash
az storage blob upload-batch --account-name stconstructiondata --destination raw-imports --source ./historical-exports/
Run the historical data import script
bash
python3 historical_import.py --source-dir ./historical-exports/ --target-db ConstructionCostDB
Run margin calculations on historical data
bash
python3 backfill_margins.py --start-date 2023-01-01 --end-date 2024-12-31
Analyze historical accuracy and generate threshold recommendations
bash
python3 calibrate_thresholds.py --output threshold_recommendations.json
Note

The historical backfill is essential for AI calibration and for establishing trust with the client — they need to see that the system would have correctly flagged past problem projects. Expect 8–16 hours of data cleaning and transformation work. Common issues: inconsistent cost codes across projects, missing budget entries, transactions dated incorrectly. Work closely with the client's controller during this phase. The calibration script analyzes completed projects and recommends optimal alert thresholds based on the client's actual margin erosion patterns.

Step 10: Security Hardening and Access Control

Implement comprehensive security controls for the financial data flowing through the system. Configure Azure AD authentication, Row-Level Security in Power BI, SQL Database auditing, and network access controls. Ensure compliance with the client's surety bonding requirements for data integrity and auditability.

1
Enable Azure SQL Auditing
2
Enable Advanced Threat Protection
3
Configure SQL Firewall — allow only Azure services and MSP management IPs
4
Enable Azure Key Vault soft delete and purge protection
5
Create Azure AD security groups for RBAC
Enable Azure SQL Auditing
bash
az sql server audit-policy update --resource-group rg-construction-analytics --name sql-construction-analytics --state Enabled --storage-account stconstructiondata
Enable Advanced Threat Protection
bash
az sql db threat-policy update --resource-group rg-construction-analytics --server sql-construction-analytics --name ConstructionCostDB --state Enabled --email-addresses 'msp-security@yourmsp.com'
Configure SQL Firewall
bash
# allow only Azure services and MSP management IPs

az sql server firewall-rule create --resource-group rg-construction-analytics --server sql-construction-analytics --name AllowAzureServices --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
az sql server firewall-rule create --resource-group rg-construction-analytics --server sql-construction-analytics --name MSP-Management --start-ip-address <MSP_STATIC_IP> --end-ip-address <MSP_STATIC_IP>
Enable Azure Key Vault soft delete and purge protection
bash
az keyvault update --name kv-construction --enable-soft-delete true --enable-purge-protection true
Create Azure AD security groups for RBAC
bash
az ad group create --display-name 'Construction-Analytics-Admins' --mail-nickname analytics-admins
az ad group create --display-name 'Construction-Analytics-PMs' --mail-nickname analytics-pms
az ad group create --display-name 'Construction-Analytics-Executives' --mail-nickname analytics-execs
Note

Row-Level Security (RLS) in Power BI is critical — project managers should only see their own projects to prevent scope creep in data access. Create RLS roles: 'ProjectManager' (filtered by project_manager field), 'Controller' (all projects), 'Executive' (all projects, summary views only). Test RLS by using the 'View as Role' feature in Power BI Desktop before publishing. Enable MFA for all Azure AD accounts accessing the analytics platform. Document all access controls in the client handoff documentation.

Step 11: User Acceptance Testing (UAT)

Conduct structured UAT with the client's key stakeholders over a 2-week period. Test all data flows from source systems through to dashboard visualizations and alerts. Validate margin calculations against the client's manual WIP schedules. Verify alert delivery across all channels (Teams, email, SMS). Have project managers interact with dashboards and provide feedback on usability and accuracy.

Note

Create a UAT test plan with specific test cases: (1) Verify budget data matches source system for 5 active projects, (2) Verify actual costs match accounting system GL for same 5 projects, (3) Verify margin calculations match manual WIP schedule within 1% tolerance, (4) Trigger each alert level and verify delivery, (5) Test RLS by logging in as different user roles, (6) Test Power BI mobile app access from field devices, (7) Verify AI narrative accuracy on 3 completed projects with known outcomes. Document all findings and resolve blockers before go-live. Allow 2 rounds of UAT with fixes between rounds.

Step 12: Go-Live and Hypercare

Transition from UAT to production use. Enable all scheduled ETL jobs, activate alert flows, and publish the Power BI app to all end users. Provide on-site or virtual support for the first 2 weeks of production use (hypercare period). Monitor data sync accuracy, alert delivery, and user adoption daily during hypercare. Conduct a formal go-live meeting with stakeholders to confirm system readiness and establish the ongoing support model.

1
Enable all Azure Function timer triggers (they should be disabled during UAT)
2
Verify functions are running
3
Monitor function execution logs
Enable all Azure Function timer triggers
bash
az functionapp config appsettings set --name func-construction-etl --resource-group rg-construction-analytics --settings AzureWebJobs.sync_projects.Disabled=false
az functionapp config appsettings set --name func-construction-etl --resource-group rg-construction-analytics --settings AzureWebJobs.sync_costs.Disabled=false
az functionapp config appsettings set --name func-construction-etl --resource-group rg-construction-analytics --settings AzureWebJobs.sync_commitments.Disabled=false
az functionapp config appsettings set --name func-construction-etl --resource-group rg-construction-analytics --settings AzureWebJobs.calculate_margins.Disabled=false
Verify functions are running
bash
az functionapp function list --name func-construction-etl --resource-group rg-construction-analytics -o table
Monitor function execution logs
bash
func azure functionapp logstream func-construction-etl
Note

During hypercare, check every morning: (1) Did all ETL functions run successfully overnight? (2) Are margin snapshots current? (3) Were any alerts triggered and were they accurate? (4) Are users accessing the dashboards? Track Power BI usage metrics via the admin portal. Schedule a formal hypercare closeout meeting at the end of 2 weeks to transition to standard managed services support.

Custom AI Components

Margin Erosion Detection Engine

Type: agent Core Azure Function that runs nightly to calculate margin snapshots for all active projects, detect erosion trends, generate AI-powered narratives, and trigger alerts. This is the primary intelligence component of the system. It computes Estimate at Completion (EAC), compares against original budget and contract amount, calculates margin erosion velocity (rate of change), and calls Azure OpenAI for natural-language risk assessment.

Implementation:

calculate_margins/function.json
json
{
  "scriptFile": "__init__.py",
  "bindings": [
    {
      "name": "timer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 3 * * *"
    }
  ]
}
calculate_margins/__init__.py
python
import azure.functions as func
import pyodbc
import json
import logging
from datetime import datetime, timedelta
from openai import AzureOpenAI
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

def main(timer: func.TimerRequest) -> None:
    logging.info('Margin calculation function triggered')
    
    # Load secrets from Key Vault
    credential = DefaultAzureCredential()
    kv_client = SecretClient(vault_url='https://kv-construction.vault.azure.net/', credential=credential)
    
    sql_conn_str = kv_client.get_secret('SqlConnectionString').value
    openai_endpoint = kv_client.get_secret('OpenAIEndpoint').value
    openai_key = kv_client.get_secret('OpenAIKey').value
    
    # Initialize OpenAI client
    ai_client = AzureOpenAI(
        azure_endpoint=openai_endpoint,
        api_key=openai_key,
        api_version='2024-06-01'
    )
    
    conn = pyodbc.connect(sql_conn_str)
    cursor = conn.cursor()
    
    # Get all active projects
    cursor.execute("""
        SELECT project_id, project_name, project_number, project_manager,
               contract_amount, original_budget, revised_budget, project_type,
               bonded, prevailing_wage, start_date, estimated_completion_date
        FROM construction.dim_projects
        WHERE project_status = 'Active'
    """)
    projects = cursor.fetchall()
    
    today = datetime.utcnow().date()
    
    for project in projects:
        project_id = project.project_id
        contract_amount = float(project.contract_amount or 0)
        original_budget = float(project.original_budget or 0)
        
        # Calculate cost to date
        cursor.execute("""
            SELECT ISNULL(SUM(amount), 0) as cost_to_date
            FROM construction.fact_actual_costs
            WHERE project_id = ?
        """, project_id)
        cost_to_date = float(cursor.fetchone().cost_to_date)
        
        # Calculate committed but not yet invoiced
        cursor.execute("""
            SELECT ISNULL(SUM(CASE WHEN remaining_to_invoice > 0 THEN remaining_to_invoice ELSE 0 END), 0) as committed_remaining
            FROM construction.fact_commitments
            WHERE project_id = ? AND status = 'Open'
        """, project_id)
        committed_remaining = float(cursor.fetchone().committed_remaining)
        
        # Get latest cost-to-complete estimate from PMs
        cursor.execute("""
            SELECT TOP 1 estimated_cost_to_complete, percent_complete, entered_by, notes
            FROM construction.fact_cost_to_complete
            WHERE project_id = ?
            ORDER BY estimate_date DESC
        """, project_id)
        ctc_row = cursor.fetchone()
        
        if ctc_row:
            est_cost_to_complete = float(ctc_row.estimated_cost_to_complete)
            percent_complete = float(ctc_row.percent_complete or 0)
        else:
            # Fallback: estimate CTC based on committed + budget remaining
            est_cost_to_complete = max(0, float(project.revised_budget or original_budget) - cost_to_date - committed_remaining)
            percent_complete = (cost_to_date / original_budget * 100) if original_budget > 0 else 0
        
        # Calculate Estimate at Completion (EAC)
        eac = cost_to_date + committed_remaining + est_cost_to_complete
        
        # Calculate margins
        projected_margin_amount = contract_amount - eac
        projected_margin_pct = (projected_margin_amount / contract_amount * 100) if contract_amount > 0 else 0
        original_margin_pct = ((contract_amount - original_budget) / contract_amount * 100) if contract_amount > 0 else 0
        margin_erosion_pct = projected_margin_pct - original_margin_pct
        
        # Earned Value calculations
        earned_value = contract_amount * (percent_complete / 100)
        cost_variance = earned_value - cost_to_date  # Positive = under budget
        
        # Calculate schedule variance (simplified)
        if project.start_date and project.estimated_completion_date:
            total_duration = (project.estimated_completion_date - project.start_date).days
            elapsed = (today - project.start_date).days
            planned_pct = min(100, (elapsed / total_duration * 100)) if total_duration > 0 else 0
            schedule_variance = percent_complete - planned_pct  # Positive = ahead of schedule
        else:
            schedule_variance = 0
        
        # Get previous margin snapshots for trend analysis
        cursor.execute("""
            SELECT TOP 4 snapshot_date, projected_margin_pct, estimate_at_completion
            FROM construction.fact_margin_snapshots
            WHERE project_id = ?
            ORDER BY snapshot_date DESC
        """, project_id)
        history = cursor.fetchall()
        
        # Calculate margin velocity (rate of erosion per week)
        margin_velocity = 0
        if len(history) >= 2:
            prev = history[0]
            days_diff = (today - prev.snapshot_date).days
            if days_diff > 0:
                margin_velocity = (projected_margin_pct - float(prev.projected_margin_pct)) / (days_diff / 7)
        
        # Determine alert level
        alert_triggered = False
        alert_level = 'NONE'
        
        # Load thresholds from config table
        cursor.execute("SELECT setting_value FROM construction.system_config WHERE setting_key = 'threshold_watch'")
        row = cursor.fetchone()
        threshold_watch = float(row.setting_value) if row else -2.0
        
        cursor.execute("SELECT setting_value FROM construction.system_config WHERE setting_key = 'threshold_warning'")
        row = cursor.fetchone()
        threshold_warning = float(row.setting_value) if row else -5.0
        
        cursor.execute("SELECT setting_value FROM construction.system_config WHERE setting_key = 'threshold_critical'")
        row = cursor.fetchone()
        threshold_critical = float(row.setting_value) if row else -10.0
        
        if margin_erosion_pct <= threshold_critical or projected_margin_amount < 0:
            alert_level = 'CRITICAL'
            alert_triggered = True
        elif margin_erosion_pct <= threshold_warning:
            alert_level = 'WARNING'
            alert_triggered = True
        elif margin_erosion_pct <= threshold_watch:
            alert_level = 'WATCH'
            alert_triggered = True
        
        # Generate AI narrative for flagged projects
        ai_narrative = ''
        ai_risk_score = abs(margin_erosion_pct) * 5  # Simple heuristic base score
        
        if alert_triggered or abs(margin_erosion_pct) > 1.0:
            # Get cost breakdown by type
            cursor.execute("""
                SELECT cost_type, SUM(amount) as total
                FROM construction.fact_actual_costs
                WHERE project_id = ?
                GROUP BY cost_type
                ORDER BY total DESC
            """, project_id)
            cost_breakdown = {row.cost_type: float(row.total) for row in cursor.fetchall()}
            
            # Get change order summary
            cursor.execute("""
                SELECT COUNT(*) as co_count,
                       SUM(CASE WHEN status='Approved' THEN revenue_amount ELSE 0 END) as approved_revenue,
                       SUM(CASE WHEN status='Approved' THEN cost_amount ELSE 0 END) as approved_cost,
                       SUM(CASE WHEN status='Pending' THEN revenue_amount ELSE 0 END) as pending_revenue
                FROM construction.fact_change_orders
                WHERE project_id = ?
            """, project_id)
            co = cursor.fetchone()
            
            history_text = ''
            for h in history:
                history_text += f"  {h.snapshot_date}: margin={h.projected_margin_pct:.1f}%, EAC=${h.estimate_at_completion:,.0f}\n"
            
            prompt = f"""Analyze this construction project's financial health and margin erosion risk:

PROJECT: {project.project_name} ({project.project_number})
Type: {project.project_type}
Project Manager: {project.project_manager}
Bonded: {'Yes' if project.bonded else 'No'}
Prevailing Wage: {'Yes' if project.prevailing_wage else 'No'}

FINANCIAL SUMMARY:
- Contract Amount: ${contract_amount:,.2f}
- Original Budget: ${original_budget:,.2f}
- Original Margin: {original_margin_pct:.1f}%
- Cost to Date: ${cost_to_date:,.2f}
- Committed (uninvoiced): ${committed_remaining:,.2f}
- Estimated Cost to Complete: ${est_cost_to_complete:,.2f}
- Estimate at Completion (EAC): ${eac:,.2f}
- Projected Margin: ${projected_margin_amount:,.2f} ({projected_margin_pct:.1f}%)
- Margin Erosion: {margin_erosion_pct:.1f} percentage points
- Margin Velocity: {margin_velocity:.2f} pct points/week
- Percent Complete: {percent_complete:.0f}%
- Cost Variance: ${cost_variance:,.2f}
- Schedule Variance: {schedule_variance:.1f} percentage points

COST BREAKDOWN:
{json.dumps(cost_breakdown, indent=2)}

CHANGE ORDERS:
- Total COs: {co.co_count}
- Approved Revenue: ${float(co.approved_revenue or 0):,.2f}
- Approved Cost: ${float(co.approved_cost or 0):,.2f}
- Pending Revenue (not yet approved): ${float(co.pending_revenue or 0):,.2f}

MARGIN HISTORY (most recent first):
{history_text}

Alert Level: {alert_level}

Provide:
1. A 2-3 sentence executive summary of this project's financial health
2. The primary drivers of margin erosion (be specific about cost categories)
3. Risk assessment (Low/Medium/High/Critical) with justification
4. 3 specific, actionable recommendations for the project manager
5. If there are pending change orders, comment on their potential impact

Keep the analysis factual and based solely on the numbers provided. Do not invent or assume any figures not given."""
            
            try:
                response = ai_client.chat.completions.create(
                    model='gpt4o-analysis',
                    messages=[
                        {'role': 'system', 'content': CONSTRUCTION_ANALYST_SYSTEM_PROMPT},
                        {'role': 'user', 'content': prompt}
                    ],
                    temperature=0.3,
                    max_tokens=1000
                )
                ai_narrative = response.choices[0].message.content
                
                # Extract risk score from narrative (look for risk assessment)
                if 'Critical' in ai_narrative:
                    ai_risk_score = min(100, ai_risk_score + 30)
                elif 'High' in ai_narrative:
                    ai_risk_score = min(100, ai_risk_score + 15)
            except Exception as e:
                logging.error(f'OpenAI call failed for project {project_id}: {e}')
                ai_narrative = f'AI analysis unavailable. Alert level: {alert_level}. Margin erosion: {margin_erosion_pct:.1f}%'
        
        # Insert margin snapshot
        cursor.execute("""
            INSERT INTO construction.fact_margin_snapshots
            (project_id, snapshot_date, contract_amount, revised_budget,
             cost_to_date, committed_costs, estimated_cost_to_complete,
             estimate_at_completion, original_margin_pct, earned_value,
             schedule_variance, cost_variance, ai_risk_score, ai_narrative, alert_triggered)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, project_id, today, contract_amount, float(project.revised_budget or original_budget),
             cost_to_date, committed_remaining, est_cost_to_complete,
             eac, original_margin_pct, earned_value,
             schedule_variance, cost_variance, ai_risk_score, ai_narrative,
             1 if alert_triggered else 0)
        
        if alert_triggered:
            logging.warning(f'ALERT [{alert_level}]: Project {project.project_name} - margin erosion {margin_erosion_pct:.1f}%')
    
    conn.commit()
    conn.close()
    logging.info(f'Margin calculation complete for {len(projects)} projects')


# System prompt constant
CONSTRUCTION_ANALYST_SYSTEM_PROMPT = """You are an expert construction financial analyst assistant working for a general contractor. Your role is to analyze project cost data and provide clear, actionable insights about margin erosion and financial risk.

RULES:
1. Only reference numbers explicitly provided in the data. Never invent, estimate, or hallucinate financial figures.
2. Use construction industry terminology: EAC (Estimate at Completion), CTC (Cost to Complete), WIP, cost-to-date, committed costs, earned value, cost variance, schedule variance.
3. Be direct and specific. Name the exact cost categories driving erosion.
4. Recommendations must be actionable by a project manager: e.g., 'Review subcontractor XYZ invoices against contract terms' not 'Consider reviewing costs.'
5. If the project is bonded, note implications for the surety relationship if margin continues to erode.
6. If the project is prevailing wage, consider whether labor cost overruns may be related to wage compliance.
7. Format your response with clear headers: EXECUTIVE SUMMARY, EROSION DRIVERS, RISK ASSESSMENT, RECOMMENDATIONS, CHANGE ORDER IMPACT (if applicable).
8. Keep total response under 500 words."""

ETL Data Sync Functions

Type: integration

Set of Azure Functions that extract data from the client's construction management and accounting systems via Agave API, transform it into the unified warehouse schema, and load it into Azure SQL Database. Handles incremental sync (only new/modified records) to minimize API calls and processing time.

Implementation:

sync_costs/__init__.py
python
# sync_costs/__init__.py
import azure.functions as func
import pyodbc
import requests
import json
import logging
from datetime import datetime, timedelta
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

def main(timer: func.TimerRequest) -> None:
    logging.info('Cost sync function triggered')
    
    credential = DefaultAzureCredential()
    kv_client = SecretClient(vault_url='https://kv-construction.vault.azure.net/', credential=credential)
    
    agave_api_key = kv_client.get_secret('AgaveApiKey').value
    agave_client_secret = kv_client.get_secret('AgaveClientSecret').value
    sql_conn_str = kv_client.get_secret('SqlConnectionString').value
    
    headers = {
        'API-Key': agave_api_key,
        'Client-Secret': agave_client_secret,
        'Content-Type': 'application/json'
    }
    
    conn = pyodbc.connect(sql_conn_str)
    cursor = conn.cursor()
    
    # Get last sync timestamp
    cursor.execute("""
        SELECT ISNULL(MAX(imported_at), '2020-01-01') as last_sync
        FROM construction.fact_actual_costs
    """)
    last_sync = cursor.fetchone().last_sync
    
    # Get linked accounts from Agave
    accounts_resp = requests.get('https://api.agaveapi.com/linked-accounts', headers=headers)
    accounts = accounts_resp.json().get('data', [])
    
    for account in accounts:
        account_token = account.get('account_token')
        account_headers = {**headers, 'Account-Token': account_token}
        
        # Sync projects first
        projects_resp = requests.get(
            'https://api.agaveapi.com/projects',
            headers=account_headers,
            params={'updated_after': last_sync.isoformat() if hasattr(last_sync, 'isoformat') else str(last_sync)}
        )
        
        if projects_resp.status_code == 200:
            for proj in projects_resp.json().get('data', []):
                # Upsert project into dim_projects
                cursor.execute("""
                    MERGE construction.dim_projects AS target
                    USING (SELECT ? as source_system_id) AS source
                    ON target.source_system_id = source.source_system_id AND target.source_system = ?
                    WHEN MATCHED THEN
                        UPDATE SET project_name = ?, contract_amount = ?,
                                   project_status = ?, updated_at = GETUTCDATE()
                    WHEN NOT MATCHED THEN
                        INSERT (source_system_id, source_system, project_name, project_number,
                                contract_amount, original_budget, project_status)
                        VALUES (?, ?, ?, ?, ?, ?, ?);
                """, proj.get('id'), account.get('source_system', 'unknown'),
                     proj.get('name'), proj.get('contract_value'),
                     proj.get('status', 'Active'),
                     proj.get('id'), account.get('source_system', 'unknown'),
                     proj.get('name'), proj.get('number'),
                     proj.get('contract_value'), proj.get('budget'),
                     proj.get('status', 'Active'))
        
        # Sync actual cost transactions
        # Agave normalizes these as 'invoices' or 'direct_costs' depending on source system
        for endpoint in ['invoices', 'direct-costs']:
            page = 1
            while True:
                costs_resp = requests.get(
                    f'https://api.agaveapi.com/{endpoint}',
                    headers=account_headers,
                    params={
                        'updated_after': last_sync.isoformat() if hasattr(last_sync, 'isoformat') else str(last_sync),
                        'page': page,
                        'per_page': 100
                    }
                )
                
                if costs_resp.status_code != 200:
                    logging.error(f'Failed to fetch {endpoint}: {costs_resp.status_code}')
                    break
                
                data = costs_resp.json().get('data', [])
                if not data:
                    break
                
                for cost in data:
                    # Map to our schema
                    project_source_id = cost.get('project_id')
                    
                    # Look up our internal project_id
                    cursor.execute("""
                        SELECT project_id FROM construction.dim_projects
                        WHERE source_system_id = ? AND source_system = ?
                    """, project_source_id, account.get('source_system', 'unknown'))
                    proj_row = cursor.fetchone()
                    
                    if not proj_row:
                        logging.warning(f'Project {project_source_id} not found, skipping cost')
                        continue
                    
                    internal_project_id = proj_row.project_id
                    
                    # Resolve or create cost code
                    cost_code_value = cost.get('cost_code', 'UNASSIGNED')
                    cursor.execute("""
                        SELECT cost_code_id FROM construction.dim_cost_codes
                        WHERE cost_code = ?
                    """, cost_code_value)
                    cc_row = cursor.fetchone()
                    
                    if not cc_row:
                        cursor.execute("""
                            INSERT INTO construction.dim_cost_codes (cost_code, cost_code_description, cost_type)
                            VALUES (?, ?, ?)
                        """, cost_code_value, cost.get('cost_code_description', ''),
                             cost.get('cost_type', 'Other'))
                        cursor.execute('SELECT @@IDENTITY')
                        cost_code_id = int(cursor.fetchone()[0])
                    else:
                        cost_code_id = cc_row.cost_code_id
                    
                    # Resolve or create vendor
                    vendor_name = cost.get('vendor_name', 'Unknown')
                    cursor.execute("""
                        SELECT vendor_id FROM construction.dim_vendors WHERE vendor_name = ?
                    """, vendor_name)
                    v_row = cursor.fetchone()
                    
                    if not v_row:
                        cursor.execute("""
                            INSERT INTO construction.dim_vendors (vendor_name, vendor_type) VALUES (?, ?)
                        """, vendor_name, cost.get('vendor_type', 'Supplier'))
                        cursor.execute('SELECT @@IDENTITY')
                        vendor_id = int(cursor.fetchone()[0])
                    else:
                        vendor_id = v_row.vendor_id
                    
                    # Insert cost transaction (check for duplicates)
                    source_doc = cost.get('number', cost.get('id', ''))
                    cursor.execute("""
                        SELECT actual_cost_id FROM construction.fact_actual_costs
                        WHERE project_id = ? AND source_document = ? AND amount = ?
                    """, internal_project_id, source_doc, cost.get('amount', 0))
                    
                    if not cursor.fetchone():
                        cursor.execute("""
                            INSERT INTO construction.fact_actual_costs
                            (project_id, cost_code_id, vendor_id, transaction_date,
                             amount, description, source_document, cost_type)
                            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                        """, internal_project_id, cost_code_id, vendor_id,
                             cost.get('date', datetime.utcnow().date()),
                             float(cost.get('amount', 0)),
                             cost.get('description', ''),
                             source_doc,
                             cost.get('cost_type', 'Other'))
                
                page += 1
                if len(data) < 100:
                    break
    
    conn.commit()
    conn.close()
    logging.info('Cost sync complete')


# sync_projects/__init__.py - similar structure, focused on project master data
# sync_commitments/__init__.py - similar structure for POs and subcontracts
# See sync_costs above as the template pattern; adapt the Agave endpoints:
#   Projects: GET /projects
#   Commitments: GET /purchase-orders and GET /subcontracts
#   Change Orders: GET /change-orders

Power BI Dashboard Template

Type: workflow Complete Power BI report specification with four dashboard pages, DAX measures, and visual configurations. This template is designed to be reusable across multiple construction clients with minimal customization (swap data source connection, update branding). Implementation:

Data Source Connection

  • Type: DirectQuery to Azure SQL Database
  • Server: sql-construction-analytics.database.windows.net
  • Database: ConstructionCostDB
  • Authentication: Azure Active Directory (organizational account)
  • Tables to import: All tables in the 'construction' schema

Data Model Relationships

  • dim_projects.project_id -> fact_budget.project_id (1:many)
  • dim_projects.project_id -> fact_actual_costs.project_id (1:many)
  • dim_projects.project_id -> fact_commitments.project_id (1:many)
  • dim_projects.project_id -> fact_change_orders.project_id (1:many)
  • dim_projects.project_id -> fact_cost_to_complete.project_id (1:many)
  • dim_projects.project_id -> fact_margin_snapshots.project_id (1:many)
  • dim_cost_codes.cost_code_id -> fact_budget.cost_code_id (1:many)
  • dim_cost_codes.cost_code_id -> fact_actual_costs.cost_code_id (1:many)
  • dim_vendors.vendor_id -> fact_actual_costs.vendor_id (1:many)
  • dim_vendors.vendor_id -> fact_commitments.vendor_id (1:many)

DAX Measures — Core Financial Measures

Core financial DAX measures
dax
Total Contract Amount = SUM(dim_projects[contract_amount])

Original Budget = SUM(fact_budget[original_budget_amount])

Revised Budget = SUM(fact_budget[revised_budget_amount])

Cost to Date = SUM(fact_actual_costs[amount])

Committed Costs = 
    CALCULATE(
        SUM(fact_commitments[revised_amount]),
        fact_commitments[status] = "Open"
    )

Committed Uninvoiced = 
    CALCULATE(
        SUM(fact_commitments[remaining_to_invoice]),
        fact_commitments[status] = "Open",
        fact_commitments[remaining_to_invoice] > 0
    )

Estimated Cost to Complete = 
    CALCULATE(
        SUM(fact_cost_to_complete[estimated_cost_to_complete]),
        LASTDATE(fact_cost_to_complete[estimate_date])
    )

Estimate at Completion (EAC) = 
    [Cost to Date] + [Committed Uninvoiced] + [Estimated Cost to Complete]

Projected Margin $ = [Total Contract Amount] - [Estimate at Completion (EAC)]

Projected Margin % = 
    DIVIDE([Projected Margin $], [Total Contract Amount], 0) * 100

Original Margin % = 
    DIVIDE([Total Contract Amount] - [Original Budget], [Total Contract Amount], 0) * 100

Margin Erosion % = [Projected Margin %] - [Original Margin %]

Budget Variance = [Revised Budget] - [Estimate at Completion (EAC)]

Budget Variance % = DIVIDE([Budget Variance], [Revised Budget], 0) * 100

Percent Complete = 
    CALCULATE(
        MAX(fact_cost_to_complete[percent_complete]),
        LASTDATE(fact_cost_to_complete[estimate_date])
    )

Cost Performance Index (CPI) = 
    VAR EV = [Total Contract Amount] * [Percent Complete] / 100
    RETURN DIVIDE(EV, [Cost to Date], 1)

DAX Measures — Alert Measures

Alert and health status DAX measures
dax
Margin Health Status = 
    SWITCH(
        TRUE(),
        [Margin Erosion %] <= -10 || [Projected Margin $] < 0, "CRITICAL",
        [Margin Erosion %] <= -5, "WARNING",
        [Margin Erosion %] <= -2, "WATCH",
        "HEALTHY"
    )

Margin Health Color = 
    SWITCH(
        [Margin Health Status],
        "CRITICAL", "#D32F2F",
        "WARNING", "#F57C00",
        "WATCH", "#FBC02D",
        "#388E3C"
    )

AI Risk Score = 
    CALCULATE(
        MAX(fact_margin_snapshots[ai_risk_score]),
        LASTDATE(fact_margin_snapshots[snapshot_date])
    )

Latest AI Narrative = 
    CALCULATE(
        MAX(fact_margin_snapshots[ai_narrative]),
        LASTDATE(fact_margin_snapshots[snapshot_date])
    )

Projects at Risk Count = 
    CALCULATE(
        COUNTROWS(dim_projects),
        FILTER(
            dim_projects,
            [Margin Health Status] IN {"CRITICAL", "WARNING"}
        )
    )

Page 1: Portfolio Overview

  • Layout: Single scrollable page
  • Top KPI Cards (horizontal row): Active Projects Count, Total Contract Value (sum), Total Projected Margin $ (sum), Average Projected Margin %, Projects at Risk Count (with red conditional formatting)
  • Project Health Matrix (table visual) — Columns: Project Name, PM, Contract $, Budget $, Cost to Date, EAC, Projected Margin %, Margin Erosion %, Health Status (icon + color), AI Risk Score | Conditional formatting: row background by Margin Health Color | Sort: by AI Risk Score descending (worst first) | Drill-through: click project name to go to Page 2
  • Margin Distribution Chart (donut) — Segments: HEALTHY, WATCH, WARNING, CRITICAL | Colors: green, yellow, orange, red
  • Portfolio Margin Trend (line chart) — X-axis: snapshot_date (weekly) | Y-axis: average projected margin % across all projects | Show trend line

Page 2: Project Deep Dive (drill-through)

  • Context: Filtered to selected project via drill-through
  • Project Header: Project name, number, PM, client, status, percent complete
  • Waterfall Chart: Original Budget -> Approved Changes -> Revised Budget -> Cost to Date -> Committed -> CTC -> EAC -> Contract Amount (showing margin as remainder)
  • Cost Breakdown by Category (stacked bar): Labor, Material, Subcontractor, Equipment, Other — budget vs. actual vs. EAC
  • Cost Code Detail Table: Cost code, budget, actual, committed, CTC, EAC, variance, variance %
  • Change Order Summary (table): CO#, description, revenue, cost, margin impact, status
  • AI Analysis Card (text box): Display [Latest AI Narrative] formatted text
  • Project Slicer: Multi-select dropdown for project name
  • Margin Trend Lines (line chart) — X-axis: snapshot_date | Y-axis: projected_margin_pct | Multiple lines (one per project) or single line if one project selected | Reference lines at threshold values (-2%, -5%, -10%)
  • EAC Growth Chart (area chart): Shows how EAC has grown over time vs. original budget (constant line)
  • Margin Velocity Table: Project, current margin %, 30-day change, 60-day change, velocity (pct/week)
  • Erosion Drivers (decomposition tree): Drill from total erosion -> cost type -> cost code -> vendor

Page 4: WIP Schedule

  • Layout: Formatted to match standard WIP schedule for surety review
  • WIP Table (matrix visual) — Rows: Project Name | Columns: Contract Amount, Cost to Date, Est Cost to Complete, Est Total Cost, % Complete (cost method), Billings to Date, Earned Revenue, Over/Under Billing | Totals row at bottom
  • Over/Under Billing Summary (KPI cards): Total overbilled, total underbilled, net position
  • Export button: Configured to export to formatted Excel matching standard surety WIP template

Row-Level Security

  • Role: ProjectManager — DAX filter: [project_manager] = USERPRINCIPALNAME()
  • Role: Controller — no filter, sees all
  • Role: Executive — no filter, sees all, read-only

Branding

  • Use client's logo (top left of each page)
  • Primary color: client's brand color
  • Secondary color: #37474F (dark gray)
  • Font: Segoe UI (Power BI default)
  • Footer: "Powered by [MSP Name] | Data refreshed: [last refresh timestamp]"

Weekly Executive Summary Generator

Type: prompt Azure OpenAI prompt template that generates a weekly portfolio-level executive summary email for the client's CFO/CEO. Called by the Power Automate Monday morning flow. Aggregates all active project margin data into a concise, executive-friendly narrative with red/yellow/green status indicators. Implementation:

weekly_summary_generator.py
python
# Azure Function called by Power Automate to generate weekly executive
# summary email via Azure OpenAI

# weekly_summary_generator.py
# Called by Power Automate via HTTP-triggered Azure Function

import azure.functions as func
import pyodbc
import json
from openai import AzureOpenAI
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
from datetime import datetime, timedelta

def main(req: func.HttpRequest) -> func.HttpResponse:
    credential = DefaultAzureCredential()
    kv_client = SecretClient(vault_url='https://kv-construction.vault.azure.net/', credential=credential)
    
    sql_conn_str = kv_client.get_secret('SqlConnectionString').value
    openai_endpoint = kv_client.get_secret('OpenAIEndpoint').value
    openai_key = kv_client.get_secret('OpenAIKey').value
    
    ai_client = AzureOpenAI(
        azure_endpoint=openai_endpoint,
        api_key=openai_key,
        api_version='2024-06-01'
    )
    
    conn = pyodbc.connect(sql_conn_str)
    cursor = conn.cursor()
    
    today = datetime.utcnow().date()
    week_ago = today - timedelta(days=7)
    
    # Get latest margin snapshots for all active projects
    cursor.execute("""
        SELECT p.project_name, p.project_number, p.project_manager, p.contract_amount,
               ms.estimate_at_completion, ms.projected_margin_pct, ms.ai_risk_score,
               ms.margin_erosion_pct, ms.cost_to_date, ms.alert_triggered,
               prev.projected_margin_pct as prev_margin_pct
        FROM construction.dim_projects p
        INNER JOIN construction.fact_margin_snapshots ms
            ON p.project_id = ms.project_id AND ms.snapshot_date = ?
        LEFT JOIN construction.fact_margin_snapshots prev
            ON p.project_id = prev.project_id AND prev.snapshot_date = ?
        WHERE p.project_status = 'Active'
        ORDER BY ms.ai_risk_score DESC
    """, today, week_ago)
    
    projects_data = []
    total_contract = 0
    total_eac = 0
    critical_count = 0
    warning_count = 0
    
    for row in cursor.fetchall():
        contract = float(row.contract_amount or 0)
        eac = float(row.estimate_at_completion or 0)
        margin_pct = float(row.projected_margin_pct or 0)
        erosion = float(row.margin_erosion_pct or 0)
        prev_margin = float(row.prev_margin_pct or margin_pct)
        weekly_change = margin_pct - prev_margin
        
        total_contract += contract
        total_eac += eac
        
        status = 'HEALTHY'
        if erosion <= -10 or (contract - eac) < 0:
            status = 'CRITICAL'
            critical_count += 1
        elif erosion <= -5:
            status = 'WARNING'
            warning_count += 1
        elif erosion <= -2:
            status = 'WATCH'
        
        projects_data.append({
            'name': row.project_name,
            'number': row.project_number,
            'pm': row.project_manager,
            'contract': contract,
            'eac': eac,
            'margin_pct': margin_pct,
            'erosion': erosion,
            'weekly_change': weekly_change,
            'risk_score': float(row.ai_risk_score or 0),
            'status': status
        })
    
    conn.close()
    
    portfolio_margin = ((total_contract - total_eac) / total_contract * 100) if total_contract > 0 else 0
    
    # Build the prompt
    projects_text = ''
    for p in projects_data:
        emoji = {'CRITICAL': '🔴', 'WARNING': '🟠', 'WATCH': '🟡', 'HEALTHY': '🟢'}[p['status']]
        direction = '↑' if p['weekly_change'] > 0 else '↓' if p['weekly_change'] < 0 else '→'
        projects_text += f"{emoji} {p['name']} ({p['number']}): Margin {p['margin_pct']:.1f}% {direction} ({p['weekly_change']:+.1f}% this week) | PM: {p['pm']} | Risk: {p['risk_score']:.0f}/100\n"
    
    prompt = f"""Generate a weekly construction portfolio executive summary for the week ending {today.strftime('%B %d, %Y')}.

PORTFOLIO OVERVIEW:
- Active Projects: {len(projects_data)}
- Total Contract Value: ${total_contract:,.0f}
- Total EAC: ${total_eac:,.0f}
- Portfolio Margin: {portfolio_margin:.1f}%
- Critical Projects: {critical_count}
- Warning Projects: {warning_count}

PROJECT STATUS:
{projects_text}

Generate a professional executive summary email body (no subject line needed) that:
1. Opens with a 2-sentence portfolio health overview
2. Highlights the top 3 risk areas requiring executive attention
3. Notes any projects that improved or deteriorated significantly this week (>1% margin change)
4. Closes with 2-3 recommended executive actions
5. Keep it under 400 words
6. Use a professional but accessible tone — this goes to a construction company CEO/CFO who is not a data analyst
7. Include the project status emojis for quick scanning"""
    
    response = ai_client.chat.completions.create(
        model='gpt4o-analysis',
        messages=[
            {'role': 'system', 'content': 'You are a construction financial advisor writing a weekly executive briefing for a contractor CEO/CFO. Be concise, specific, and action-oriented. Use construction industry language.'},
            {'role': 'user', 'content': prompt}
        ],
        temperature=0.4,
        max_tokens=800
    )
    
    summary_html = response.choices[0].message.content
    
    # Return HTML-formatted email body for Power Automate
    return func.HttpResponse(
        json.dumps({
            'subject': f'Weekly Project Portfolio Summary — {today.strftime("%B %d, %Y")} | {critical_count} Critical, {warning_count} Warning',
            'body': summary_html,
            'critical_count': critical_count,
            'warning_count': warning_count,
            'portfolio_margin': round(portfolio_margin, 1)
        }),
        mimetype='application/json'
    )

Cost-to-Complete Input Form

Type: workflow A Microsoft Power Apps canvas app (or SharePoint list with Power Automate flow) that allows project managers to submit their monthly cost-to-complete estimates directly from their phone or laptop. Data flows into the Azure SQL data warehouse to update the fact_cost_to_complete table. This is critical because the PM's subjective estimate of remaining work is the most important input to margin forecasting.

Implementation

Canvas app for iOS/Android/Web that PMs use to submit CTC estimates. Alternatively, implement as a SharePoint List + Power Automate flow (simpler to maintain).

SharePoint List: "CTC Estimates" — Columns

  • ProjectName (Lookup → Projects list or Choice column populated from Azure SQL)
  • CostCode (Choice column, populated from dim_cost_codes)
  • CostToDate_System (Number, auto-populated, read-only — pulled from Azure SQL)
  • CommittedNotInvoiced_System (Number, auto-populated, read-only)
  • EstimatedCostToComplete (Number, required — PM enters this)
  • PercentComplete (Number 0-100, required — PM enters this)
  • Notes (Multi-line text, optional)
  • SubmittedBy (Person, auto-populated)
  • SubmittedDate (Date, auto-populated)
  • Status (Choice: Draft, Submitted, Approved)

Power Automate Flow: "Sync CTC to Azure SQL"

  • Trigger: When an item is created or modified (SharePoint)
  • Condition: Status = 'Submitted'
1
Get item details from SharePoint
2
Execute SQL query (Azure SQL connector)
3
Update SharePoint item: Status = 'Approved'
4
Send confirmation email to PM
Azure SQL INSERT executed by Power Automate on CTC submission
sql
INSERT INTO construction.fact_cost_to_complete
   (project_id, cost_code_id, estimate_date, cost_to_date,
    committed_not_invoiced, estimated_cost_to_complete,
    percent_complete, entered_by, notes)
   SELECT 
     p.project_id, 
     cc.cost_code_id,
     CAST(GETUTCDATE() AS DATE),
     @CostToDate_System,
     @CommittedNotInvoiced_System,
     @EstimatedCostToComplete,
     @PercentComplete,
     @SubmittedBy,
     @Notes
   FROM construction.dim_projects p
   CROSS JOIN construction.dim_cost_codes cc
   WHERE p.project_name = @ProjectName
     AND cc.cost_code = @CostCode

Pre-population Flow: "Refresh CTC Form Data"

  • Trigger: Recurrence (daily at 6 AM)
1
Query Azure SQL for each active project's latest cost_to_date and committed costs
2
Update SharePoint list items with current system figures
3
Send Teams reminder to PMs who haven't submitted CTC in >30 days

PM Reminder Flow: "CTC Submission Reminder"

  • Trigger: Recurrence (1st and 15th of each month at 8 AM)
1
Query Azure SQL for projects without CTC update in last 14 days
2
For each, send Teams adaptive card to the assigned PM: project name and current financial snapshot, an "Update CTC" button linking to the SharePoint form, and a summary line — "Cost to Date: $X | Last CTC Estimate: $Y on [date]"

Historical Data Import and Threshold Calibration Scripts

Type: skill Python scripts for one-time historical data backfill from CSV/Excel exports and automated threshold calibration based on analysis of completed projects. Used during Phase 5 (Step 9) of implementation.

Implementation

historical_import.py
python
# One-time script to import historical project data from CSV/Excel exports

# historical_import.py
# One-time script to import historical project data from CSV/Excel exports

import pandas as pd
import pyodbc
import argparse
import os
import logging
from datetime import datetime

logging.basicConfig(level=logging.INFO)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--source-dir', required=True, help='Directory containing CSV/Excel exports')
    parser.add_argument('--target-db', required=True, help='Target database name')
    parser.add_argument('--sql-server', default='sql-construction-analytics.database.windows.net')
    parser.add_argument('--sql-user', default='sqladmin')
    parser.add_argument('--sql-password', required=True)
    args = parser.parse_args()
    
    conn_str = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={args.sql_server};DATABASE={args.target_db};UID={args.sql_user};PWD={args.sql_password};Encrypt=yes;TrustServerCertificate=no'
    conn = pyodbc.connect(conn_str)
    cursor = conn.cursor()
    
    # Process each file in the source directory
    for filename in os.listdir(args.source_dir):
        filepath = os.path.join(args.source_dir, filename)
        
        if filename.lower().endswith('.csv'):
            df = pd.read_csv(filepath)
        elif filename.lower().endswith(('.xlsx', '.xls')):
            df = pd.read_excel(filepath)
        else:
            continue
        
        logging.info(f'Processing {filename}: {len(df)} rows')
        
        # Detect file type by column names and import accordingly
        cols_lower = [c.lower().strip() for c in df.columns]
        
        if any('project' in c and 'name' in c for c in cols_lower):
            import_projects(cursor, df)
        elif any('cost' in c and 'code' in c for c in cols_lower) and any('budget' in c for c in cols_lower):
            import_budgets(cursor, df)
        elif any('amount' in c for c in cols_lower) and any('date' in c for c in cols_lower):
            import_costs(cursor, df)
        else:
            logging.warning(f'Could not determine file type for {filename}')
    
    conn.commit()
    conn.close()
    logging.info('Historical import complete')

def import_projects(cursor, df):
    """Import project master data from typical accounting export format."""
    # Normalize column names
    df.columns = [c.lower().strip().replace(' ', '_') for c in df.columns]
    
    for _, row in df.iterrows():
        project_name = str(row.get('project_name', row.get('job_name', '')))
        project_number = str(row.get('project_number', row.get('job_number', '')))
        contract_amount = float(row.get('contract_amount', row.get('contract_value', 0)) or 0)
        budget = float(row.get('original_budget', row.get('budget', contract_amount * 0.85)) or 0)
        status = str(row.get('status', row.get('project_status', 'Complete')))
        
        cursor.execute("""
            IF NOT EXISTS (SELECT 1 FROM construction.dim_projects WHERE project_name = ? AND source_system = 'historical')
            INSERT INTO construction.dim_projects
            (source_system_id, source_system, project_name, project_number,
             contract_amount, original_budget, revised_budget, project_status)
            VALUES (?, 'historical', ?, ?, ?, ?, ?, ?)
        """, project_name, project_number, project_name, project_number,
             contract_amount, budget, budget, status)
    
    logging.info(f'Imported {len(df)} projects')

def import_costs(cursor, df):
    """Import historical cost transactions."""
    df.columns = [c.lower().strip().replace(' ', '_') for c in df.columns]
    imported = 0
    
    for _, row in df.iterrows():
        project_name = str(row.get('project_name', row.get('job_name', '')))
        
        cursor.execute("""
            SELECT project_id FROM construction.dim_projects WHERE project_name = ?
        """, project_name)
        proj = cursor.fetchone()
        if not proj:
            continue
        
        amount = float(row.get('amount', row.get('cost', 0)) or 0)
        trans_date = row.get('date', row.get('transaction_date', datetime.now().date()))
        cost_code_val = str(row.get('cost_code', 'UNASSIGNED'))
        cost_type = str(row.get('cost_type', row.get('type', 'Other')))
        
        # Ensure cost code exists
        cursor.execute("SELECT cost_code_id FROM construction.dim_cost_codes WHERE cost_code = ?", cost_code_val)
        cc = cursor.fetchone()
        if not cc:
            cursor.execute("INSERT INTO construction.dim_cost_codes (cost_code, cost_type) VALUES (?, ?)", cost_code_val, cost_type)
            cursor.execute('SELECT @@IDENTITY')
            cc_id = int(cursor.fetchone()[0])
        else:
            cc_id = cc.cost_code_id
        
        cursor.execute("""
            INSERT INTO construction.fact_actual_costs
            (project_id, cost_code_id, transaction_date, amount, cost_type, description)
            VALUES (?, ?, ?, ?, ?, ?)
        """, proj.project_id, cc_id, trans_date, amount, cost_type,
             str(row.get('description', row.get('memo', ''))))
        imported += 1
    
    logging.info(f'Imported {imported} cost transactions')

def import_budgets(cursor, df):
    """Import budget line items."""
    df.columns = [c.lower().strip().replace(' ', '_') for c in df.columns]
    
    for _, row in df.iterrows():
        project_name = str(row.get('project_name', row.get('job_name', '')))
        cursor.execute("SELECT project_id FROM construction.dim_projects WHERE project_name = ?", project_name)
        proj = cursor.fetchone()
        if not proj:
            continue
        
        cost_code_val = str(row.get('cost_code', 'UNASSIGNED'))
        cursor.execute("SELECT cost_code_id FROM construction.dim_cost_codes WHERE cost_code = ?", cost_code_val)
        cc = cursor.fetchone()
        if not cc:
            cursor.execute("INSERT INTO construction.dim_cost_codes (cost_code) VALUES (?)", cost_code_val)
            cursor.execute('SELECT @@IDENTITY')
            cc_id = int(cursor.fetchone()[0])
        else:
            cc_id = cc.cost_code_id
        
        budget_amt = float(row.get('budget_amount', row.get('original_budget', 0)) or 0)
        
        cursor.execute("""
            INSERT INTO construction.fact_budget
            (project_id, cost_code_id, original_budget_amount, budget_date, snapshot_date)
            VALUES (?, ?, ?, ?, ?)
        """, proj.project_id, cc_id, budget_amt,
             row.get('budget_date', datetime.now().date()), datetime.now().date())
    
    logging.info(f'Imported {len(df)} budget line items')

if __name__ == '__main__':
    main()
calibrate_thresholds.py
python
# Analyzes completed projects to recommend optimal alert thresholds

# calibrate_thresholds.py
# Analyzes completed projects to recommend optimal alert thresholds

import pyodbc
import json
import argparse
import numpy as np
from datetime import datetime

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--output', default='threshold_recommendations.json')
    parser.add_argument('--sql-server', default='sql-construction-analytics.database.windows.net')
    parser.add_argument('--sql-user', default='sqladmin')
    parser.add_argument('--sql-password', required=True)
    parser.add_argument('--target-db', default='ConstructionCostDB')
    args = parser.parse_args()
    
    conn_str = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={args.sql_server};DATABASE={args.target_db};UID={args.sql_user};PWD={args.sql_password};Encrypt=yes'
    conn = pyodbc.connect(conn_str)
    cursor = conn.cursor()
    
    # Get completed projects with their original margin and final margin
    cursor.execute("""
        SELECT p.project_id, p.project_name, p.contract_amount, p.original_budget,
               (
                   SELECT SUM(amount) FROM construction.fact_actual_costs
                   WHERE project_id = p.project_id
               ) as total_actual_cost
        FROM construction.dim_projects p
        WHERE p.project_status = 'Complete'
          AND p.contract_amount > 0
    """)
    
    results = []
    for row in cursor.fetchall():
        contract = float(row.contract_amount or 0)
        budget = float(row.original_budget or 0)
        actual = float(row.total_actual_cost or 0)
        
        if contract == 0:
            continue
        
        original_margin_pct = (contract - budget) / contract * 100
        final_margin_pct = (contract - actual) / contract * 100
        erosion = final_margin_pct - original_margin_pct
        
        results.append({
            'project': row.project_name,
            'original_margin': round(original_margin_pct, 2),
            'final_margin': round(final_margin_pct, 2),
            'erosion': round(erosion, 2),
            'loss': final_margin_pct < 0
        })
    
    conn.close()
    
    if not results:
        print('No completed projects found for calibration')
        return
    
    erosions = [r['erosion'] for r in results]
    loss_erosions = [r['erosion'] for r in results if r['loss']]
    
    recommendations = {
        'analysis_date': datetime.now().isoformat(),
        'projects_analyzed': len(results),
        'projects_with_loss': len([r for r in results if r['loss']]),
        'avg_margin_erosion': round(np.mean(erosions), 2),
        'median_margin_erosion': round(np.median(erosions), 2),
        'std_margin_erosion': round(np.std(erosions), 2),
        'recommended_thresholds': {
            'watch': round(np.percentile(erosions, 25), 1),
            'warning': round(np.percentile(erosions, 10), 1),
            'critical': round(min(np.percentile(erosions, 5), -10), 1)
        },
        'rationale': f'Based on {len(results)} completed projects. '
                     f'Average erosion was {np.mean(erosions):.1f}%. '
                     f'{len(loss_erosions)} projects resulted in losses. '
                     f'Thresholds set at 25th/10th/5th percentiles of erosion distribution.',
        'project_details': results
    }
    
    with open(args.output, 'w') as f:
        json.dump(recommendations, f, indent=2)
    
    print(f'Threshold recommendations saved to {args.output}')
    print(f'  WATCH: {recommendations["recommended_thresholds"]["watch"]}%')
    print(f'  WARNING: {recommendations["recommended_thresholds"]["warning"]}%')
    print(f'  CRITICAL: {recommendations["recommended_thresholds"]["critical"]}%')

if __name__ == '__main__':
    main()

Testing & Validation

  • DATA INTEGRITY TEST: For 5 active projects, manually export budget and actual cost data from the client's accounting system. Compare line-by-line against the Azure SQL data warehouse tables. All budget amounts must match within $0.01 and all actual costs must match within $1.00 (rounding tolerance). Document any discrepancies and trace to root cause.
  • MARGIN CALCULATION TEST: Using the same 5 projects, manually calculate EAC (Cost to Date + Committed Uninvoiced + Estimated Cost to Complete), projected margin, and margin erosion in Excel. Compare against the fact_margin_snapshots table output. All calculated values must match within 0.1 percentage points.
  • WIP SCHEDULE VALIDATION: Generate the WIP Schedule report from the Power BI Page 4 and compare against the client's most recent manually prepared WIP schedule (from their CPA or controller). All line items must reconcile. The Over/Under billing totals must match the client's GL within 1%.
  • ALERT TRIGGER TEST — WATCH LEVEL: Temporarily modify one project's cost-to-complete estimate to create exactly 3% margin erosion. Verify that within 24 hours: (1) the margin snapshot shows correct erosion calculation, (2) a Teams notification is sent to the project manager, (3) the Power BI dashboard shows yellow WATCH status for this project.
  • ALERT TRIGGER TEST — WARNING LEVEL: Modify a test project to create 7% margin erosion. Verify: (1) email sent to PM + controller with AI narrative, (2) AI narrative accurately describes the erosion drivers, (3) dashboard shows orange WARNING status.
  • ALERT TRIGGER TEST — CRITICAL LEVEL: Modify a test project to show a projected loss (EAC > contract amount). Verify: (1) email AND SMS sent to PM + controller + CFO, (2) AI narrative includes specific recommended actions, (3) dashboard shows red CRITICAL status with risk score > 80.
  • ETL RELIABILITY TEST: Monitor Azure Functions execution for 5 consecutive business days. Verify: (1) all four functions execute on schedule with no failures (check Application Insights), (2) data freshness in Azure SQL is within 4 hours of source system, (3) no duplicate records created by re-runs.
  • AI NARRATIVE ACCURACY TEST: Select 3 completed historical projects where the final outcome is known (one profitable, one break-even, one loss). Run the margin analysis AI against the mid-project data snapshot. Evaluate whether the AI correctly identified the risk trajectory and provided relevant recommendations. Score each narrative on: factual accuracy (no hallucinated numbers), relevance of recommendations, and clarity of communication.
  • POWER BI ROW-LEVEL SECURITY TEST: Log into Power BI Service as three different users: (1) a project manager — verify they can ONLY see their own projects, (2) the controller — verify they can see ALL projects, (3) an executive — verify they see all projects in summary views. Document results with screenshots.
  • MOBILE ACCESS TEST: Access the Power BI dashboard from the Power BI mobile app on both iOS and Android devices. Verify: (1) all four pages render correctly, (2) drill-through navigation works, (3) KPI cards are readable on phone screen, (4) the app works over cellular data (simulating field conditions).
  • END-TO-END LATENCY TEST: Create a new cost transaction in the client's accounting system (e.g., enter a vendor invoice). Measure the time until it appears in: (1) the Azure SQL data warehouse, (2) the Power BI dashboard. Acceptable latency: <6 hours for data warehouse, <8 hours for dashboard (accounting for refresh schedule).
  • HISTORICAL BACKFILL VALIDATION: After importing historical data, verify that the margin trend chart (Page 3) shows a reasonable trajectory for at least 3 completed projects. The final data point for completed projects should match the known actual margin within 1 percentage point.

Client Handoff

The client handoff should be conducted as a structured 3-hour session (can be split across two meetings) with the following agenda and attendees:

Note

ATTENDEES: Client CFO/CEO, Controller, all Project Managers who will use the system, the client's designated 'data champion', MSP Project Manager, MSP technical lead.

SESSION 1: Executive Overview & Dashboard Training (90 minutes)

1
System overview: what data flows where, how often, and what the outputs mean (15 min)
2
Live walkthrough of Portfolio Overview dashboard — explain every KPI, what 'good' looks like (15 min)
3
Project Deep Dive demo — select a real active project, walk through the waterfall chart, cost breakdown, AI narrative (20 min)
4
Margin Erosion Trends — show how to read trend lines, understand velocity, and use the decomposition tree (15 min)
5
WIP Schedule — demonstrate automated WIP generation, show how to export for surety review (10 min)
6
Alert system demo — show example of each alert level (WATCH/WARNING/CRITICAL), explain response expectations (15 min)

SESSION 2: PM Training & Process Integration (90 minutes)

1
Cost-to-Complete input form training — every PM practices submitting a CTC estimate on their actual project (30 min)
2
Establishing the CTC update cadence: agree on weekly vs. biweekly vs. monthly schedule and set calendar reminders (10 min)
3
How to interpret AI narratives — what the AI can and cannot tell you, when to trust vs. verify (15 min)
4
Power BI mobile app setup on every PM's phone (15 min)
5
Troubleshooting common issues: data not refreshing, cost code mismatches, how to contact MSP support (10 min)
6
Q&A and feedback collection (10 min)

Documentation to Leave Behind

  • Quick Reference Card (laminated, 1 page): Dashboard URL, login steps, CTC form link, alert thresholds, MSP support contact
  • Power BI User Guide (10-15 pages): Screenshots of each dashboard page with annotations explaining every visual
  • CTC Submission Process Guide (2 pages): Step-by-step instructions with screenshots for the SharePoint form
  • Alert Response Playbook (3 pages): For each alert level, what it means, who should respond, and recommended actions
  • System Architecture Diagram: One-page visual showing all data flows for the controller's reference
  • Data Governance Policy (2 pages): Who has access to what, how to request changes, backup/retention policies

Success Criteria to Review

Maintenance

Ongoing MSP Responsibilities

Daily (Automated Monitoring — 15 min/day)

  • Check Azure Function execution logs via Application Insights dashboard. All four functions (sync_projects, sync_costs, sync_commitments, calculate_margins) should show successful execution. Set up Application Insights alerts for any function failure.
  • Verify Power BI dataset refresh completed successfully (check Power BI Service admin portal).
  • Review any margin erosion alerts triggered overnight — confirm they were delivered and are accurate.

Weekly (30 min/week)

  • Review the weekly executive summary email for accuracy and relevance before it goes to the client CEO/CFO (or set up auto-send after initial confidence period of 4 weeks).
  • Check Azure SQL Database DTU utilization — if consistently above 80%, evaluate scaling to S3 tier.
  • Review Power BI usage analytics — track which PMs are actually using the dashboards and follow up with non-adopters.
  • Verify CTC form submissions are flowing correctly from SharePoint to Azure SQL.

Monthly (2-4 hours/month)

  • Update cost code mappings if the client adds new cost codes to their accounting system.
  • Review and adjust alert thresholds based on client feedback — if too many false positives, tighten thresholds; if alerts are being missed, loosen them.
  • Azure consumption cost review — ensure costs are tracking to budget, optimize any runaway resources.
  • Power BI report maintenance — fix any broken visuals, update filters for new projects, add any requested enhancements.
  • Security review — check Azure AD access logs for any unusual activity, verify RLS is functioning correctly.

Quarterly (4-8 hours/quarter)

  • AI model recalibration — run the calibrate_thresholds.py script against newly completed projects to update threshold recommendations. Adjust system_config table if warranted.
  • Review and update the AI system prompt based on narrative quality feedback from the client. If narratives are too generic, add more construction-specific context.
  • Dashboard enhancement sprint — gather client feedback and implement 1-2 improvements per quarter (new visuals, additional drill-throughs, new report pages).
  • Update all Azure Functions dependencies (pip packages) and test in staging before deploying to production.
  • Conduct a brief data quality audit: check for orphaned records, duplicate transactions, cost code inconsistencies.

Annually

  • Full security audit: review all Azure RBAC assignments, Key Vault access policies, SQL firewall rules, Power BI workspace permissions.
  • Renew all software licenses and review pricing — negotiate Microsoft CSP renewals, Agave subscription, Twilio credits.
  • Conduct client business review: present ROI analysis showing early-detection value (margin saved by catching erosion earlier), system usage statistics, and planned enhancements for the coming year.
  • Archive completed project data older than 3 years to Azure cold storage to manage database size and costs.

SLA Considerations

  • Data freshness SLA: All dashboard data within 8 business hours of source system entry.
  • Alert delivery SLA: Critical alerts delivered within 1 hour of nightly margin calculation.
  • Dashboard availability SLA: 99.5% uptime (aligns with Azure SQL and Power BI Service SLAs).
  • Incident response: P1 (data pipeline failure) — 4 hour response; P2 (dashboard error) — 8 hour response; P3 (enhancement request) — next monthly maintenance window.

Escalation Paths

  • Level 1 (MSP Help Desk): Basic user access issues, Power BI login problems, CTC form questions.
  • Level 2 (MSP Integration Engineer): ETL pipeline failures, data sync discrepancies, Azure resource issues.
  • Level 3 (MSP BI Developer / Data Analyst): Dashboard defects, DAX calculation errors, AI narrative quality issues.
  • Level 4 (Vendor Escalation): Agave API issues → Agave support; Azure service issues → Microsoft support; Accounting system issues → client's accounting software vendor.

Model Retraining Triggers

  • After every 5 projects complete, re-run threshold calibration.
  • If the client changes their cost code structure significantly (e.g., adopting CSI MasterFormat), re-map all integration mappings.
  • If more than 30% of alerts are flagged as false positives by the client in a month, recalibrate immediately.
  • When the client's project mix changes materially (e.g., starts taking on a new project type like industrial), update the AI system prompt with relevant context.

Alternatives

...

Briq — Turnkey AI Construction Financial Platform

Instead of building a custom Power BI + Azure stack, deploy Briq as the primary analytics and AI layer. Briq is purpose-built for construction financial automation with pre-built predictive AI for cash flow forecasting, margin prediction, and billing automation. It connects natively to Sage, QuickBooks, Procore, and other construction platforms. The MSP would handle implementation, configuration, and ongoing management rather than building custom ETL and AI components.

Mastt — Lightweight Project Cost Control Dashboard

Deploy Mastt as a lighter-weight alternative that provides real-time project cost dashboards with cash flow visualization. Mastt offers a free project cost dashboard tier and paid plans for advanced features. It connects to various construction accounting systems and provides visual cost tracking without the full AI narrative generation of the primary approach.

QuickBooks + Excel — Manual Enhanced Approach

For the smallest contractors who cannot justify the cost of a full analytics platform, enhance their existing QuickBooks + Excel workflow with structured templates, pivot tables, and basic Power Automate alerts. Create Excel templates with formulas that calculate EAC, margin erosion, and WIP schedules. Use Power Automate to send email reminders for CTC updates and flag projects where costs exceed a percentage of budget.

Procore + Sage Intacct Native Integration

If the client already uses Procore and Sage Intacct, leverage the native bi-directional integration between these platforms rather than building a separate data warehouse. Procore's cost management module provides real-time budget vs. actual tracking, and Sage Intacct provides job costing and WIP reporting. Add Power BI on top of Sage Intacct's data layer (which has a well-documented API) for custom dashboards, and use Azure OpenAI only for the narrative generation component.

On-Premises Deployment with Dell PowerEdge Server

For clients with strict data sovereignty requirements, poor internet connectivity at their main office, or those doing CMMC-regulated federal construction work, deploy the entire solution on-premises using a Dell PowerEdge T360 server running SQL Server Standard, Power BI Report Server (on-premises), and a local Python ETL service. Azure OpenAI calls still go to the cloud but all financial data stays on-premises.

Want early access to the full toolkit?