feat: add 5 helpers (Job, Bid, ChangeOrder, Rfi, PayApp)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s

This commit is contained in:
Jonathan Miller
2026-06-23 11:48:27 -05:00
parent 6930e1881d
commit 71e9bcff24
5 changed files with 895 additions and 0 deletions
@@ -0,0 +1,135 @@
<?php
namespace Moko\Plugin\System\MokoSuiteConstruction\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Bid management — create bids with line items, level subcontractor bids, win rate analytics.
*
* Bids are tracked through the subcontracts table with status 'draft' representing
* an open bid and status 'executed' representing a won/awarded bid.
*/
class BidHelper
{
/**
* Create a bid (subcontract proposal) with line items as cost code entries.
*/
public static function create(int $jobId, array $data): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$bid = (object) [
'job_id' => (int) $jobId,
'subcontractor_contact_id' => (int) ($data['subcontractor_contact_id'] ?? 0),
'trade' => $data['trade'] ?? '',
'scope' => $data['scope'] ?? null,
'contract_amount' => (float) ($data['amount'] ?? 0),
'retention_pct' => (float) ($data['retention_pct'] ?? 10.00),
'status' => 'draft',
'insurance_gl_expires' => $data['insurance_gl_expires'] ?? null,
'insurance_wc_expires' => $data['insurance_wc_expires'] ?? null,
'created' => $now,
];
$db->insertObject('#__mokosuiteconstruction_subcontracts', $bid, 'id');
// Create cost code line items if provided
if (!empty($data['line_items']) && is_array($data['line_items'])) {
foreach ($data['line_items'] as $item) {
$line = (object) [
'job_id' => (int) $jobId,
'division' => $item['division'] ?? '',
'section' => $item['section'] ?? '',
'title' => $item['title'] ?? '',
'budget_amount' => (float) ($item['amount'] ?? 0),
'committed_amount' => 0.00,
'actual_amount' => 0.00,
'ordering' => (int) ($item['ordering'] ?? 0),
];
$db->insertObject('#__mokosuiteconstruction_cost_codes', $line, 'id');
}
}
return $bid;
}
/**
* Side-by-side subcontractor bid comparison for a trade on a given job.
*
* Returns all draft subcontracts for the specified trade, sorted by amount,
* allowing the GC to level bids and compare scope/pricing.
*/
public static function levelSubs(int $jobId, string $trade): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('sc.*')
->select('c.name AS subcontractor_name')
->from($db->quoteName('#__mokosuiteconstruction_subcontracts', 'sc'))
->join('LEFT', $db->quoteName('#__contact_details', 'c')
. ' ON c.id = sc.subcontractor_contact_id')
->where($db->quoteName('sc.job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('sc.trade') . ' = ' . $db->quote($trade))
->where($db->quoteName('sc.status') . ' = ' . $db->quote('draft'))
->order('sc.contract_amount ASC');
$db->setQuery($query);
$bids = $db->loadObjectList() ?: [];
if (count($bids) > 1) {
$lowest = (float) $bids[0]->contract_amount;
foreach ($bids as &$bid) {
$bid->delta_from_low = round((float) $bid->contract_amount - $lowest, 2);
$bid->pct_above_low = $lowest > 0
? round($bid->delta_from_low / $lowest * 100, 1)
: 0;
}
}
return $bids;
}
/**
* Win rate analytics across all bids (subcontracts).
*
* draft = pending, executed/in_progress/completed = won, terminated = lost.
*/
public static function getWinRate(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*) AS total')
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' IN ('
. $db->quote('executed') . ',' . $db->quote('in_progress') . ','
. $db->quote('completed') . ') THEN 1 ELSE 0 END) AS won')
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = '
. $db->quote('terminated') . ' THEN 1 ELSE 0 END) AS lost')
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = '
. $db->quote('draft') . ' THEN 1 ELSE 0 END) AS pending')
->from($db->quoteName('#__mokosuiteconstruction_subcontracts'))
);
$result = $db->loadObject();
$total = (int) $result->total;
$won = (int) $result->won;
$lost = (int) $result->lost;
$pending = (int) $result->pending;
$decided = $won + $lost;
return (object) [
'total' => $total,
'won' => $won,
'lost' => $lost,
'pending' => $pending,
'win_pct' => $decided > 0 ? round($won / $decided * 100, 1) : 0,
];
}
}
@@ -0,0 +1,165 @@
<?php
namespace Moko\Plugin\System\MokoSuiteConstruction\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Change order management — submit, approve with atomic budget update, list with running totals.
*/
class ChangeOrderHelper
{
/**
* Submit a change order for a job.
*/
public static function submit(int $jobId, string $title, string $description, float $amount, int $daysImpact = 0): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Get next CO number for this job
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(MAX(' . $db->quoteName('co_number') . '), 0) + 1')
->from($db->quoteName('#__mokosuiteconstruction_change_orders'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
);
$coNumber = (int) $db->loadResult();
$co = (object) [
'job_id' => (int) $jobId,
'co_number' => $coNumber,
'title' => $title,
'description' => $description,
'amount' => $amount,
'days_impact' => $daysImpact,
'status' => 'submitted',
'submitted_at' => $now,
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuiteconstruction_change_orders', $co, 'id');
return $co;
}
/**
* Approve a change order and atomically update the job's revised value.
*
* Uses SELECT ... FOR UPDATE to prevent concurrent budget corruption.
*/
public static function approve(int $changeOrderId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$db->transactionStart();
try {
// Lock and load the change order
$db->setQuery(
'SELECT co.* FROM ' . $db->quoteName('#__mokosuiteconstruction_change_orders', 'co')
. ' WHERE co.id = ' . (int) $changeOrderId
. ' FOR UPDATE'
);
$co = $db->loadObject();
if (!$co) {
throw new \RuntimeException('Change order not found: ' . $changeOrderId);
}
if ($co->status === 'approved') {
throw new \RuntimeException('Change order already approved: ' . $changeOrderId);
}
// Lock and load the job
$db->setQuery(
'SELECT j.* FROM ' . $db->quoteName('#__mokosuiteconstruction_jobs', 'j')
. ' WHERE j.id = ' . (int) $co->job_id
. ' FOR UPDATE'
);
$job = $db->loadObject();
if (!$job) {
throw new \RuntimeException('Job not found for change order: ' . $co->job_id);
}
// Update change order status
$coUpdate = (object) [
'id' => (int) $changeOrderId,
'status' => 'approved',
'approved_at' => $now,
'approved_by' => Factory::getApplication()->getIdentity()->id,
];
$db->updateObject('#__mokosuiteconstruction_change_orders', $coUpdate, 'id');
// Update job budget atomically
$newApproved = (float) $job->approved_changes + (float) $co->amount;
$newRevised = (float) $job->contract_value + $newApproved;
$jobUpdate = (object) [
'id' => (int) $job->id,
'approved_changes' => round($newApproved, 2),
'revised_value' => round($newRevised, 2),
'modified' => $now,
];
$db->updateObject('#__mokosuiteconstruction_jobs', $jobUpdate, 'id');
// Adjust end date if there is a schedule impact
if ((int) $co->days_impact !== 0 && $job->end_date) {
$newEnd = date('Y-m-d', strtotime($job->end_date . ' + ' . (int) $co->days_impact . ' days'));
$endUpdate = (object) [
'id' => (int) $job->id,
'end_date' => $newEnd,
];
$db->updateObject('#__mokosuiteconstruction_jobs', $endUpdate, 'id');
}
$db->transactionCommit();
} catch (\Throwable $e) {
$db->transactionRollback();
throw $e;
}
// Return refreshed CO
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteconstruction_change_orders'))
->where('id = ' . (int) $changeOrderId)
);
return $db->loadObject();
}
/**
* Get all change orders for a job with running total.
*/
public static function getForJob(int $jobId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('co.*')
->from($db->quoteName('#__mokosuiteconstruction_change_orders', 'co'))
->where($db->quoteName('co.job_id') . ' = ' . (int) $jobId)
->order('co.co_number ASC')
);
$orders = $db->loadObjectList() ?: [];
$runningTotal = 0.00;
foreach ($orders as &$order) {
if ($order->status === 'approved') {
$runningTotal += (float) $order->amount;
}
$order->running_total = round($runningTotal, 2);
}
return $orders;
}
}
@@ -0,0 +1,214 @@
<?php
namespace Moko\Plugin\System\MokoSuiteConstruction\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Job management — creation, dashboard P&L, active jobs, cost reporting.
*/
class JobHelper
{
/**
* Create a new job with auto-generated job number.
*/
public static function create(array $data): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Auto-generate job number: YYMM-NNN
$prefix = date('ym');
$db->setQuery(
$db->getQuery(true)
->select('MAX(' . $db->quoteName('job_number') . ')')
->from($db->quoteName('#__mokosuiteconstruction_jobs'))
->where($db->quoteName('job_number') . ' LIKE ' . $db->quote($prefix . '-%'))
);
$last = $db->loadResult();
if ($last) {
$seq = (int) substr($last, strrpos($last, '-') + 1) + 1;
} else {
$seq = 1;
}
$jobNumber = $prefix . '-' . str_pad($seq, 3, '0', STR_PAD_LEFT);
$job = (object) [
'job_number' => $jobNumber,
'title' => $data['title'] ?? '',
'description' => $data['description'] ?? null,
'address' => $data['address'] ?? null,
'city' => $data['city'] ?? '',
'state' => $data['state'] ?? '',
'postal_code' => $data['postal_code'] ?? '',
'owner_contact_id' => isset($data['owner_contact_id']) ? (int) $data['owner_contact_id'] : null,
'architect_contact_id' => isset($data['architect_contact_id']) ? (int) $data['architect_contact_id'] : null,
'superintendent_id' => isset($data['superintendent_id']) ? (int) $data['superintendent_id'] : null,
'contract_value' => (float) ($data['contract_value'] ?? 0),
'approved_changes' => 0.00,
'revised_value' => (float) ($data['contract_value'] ?? 0),
'retention_pct' => (float) ($data['retention_pct'] ?? 10.00),
'phase' => $data['phase'] ?? 'preconstruction',
'start_date' => $data['start_date'] ?? null,
'end_date' => $data['end_date'] ?? null,
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuiteconstruction_jobs', $job, 'id');
return $job;
}
/**
* Get job dashboard with P&L summary.
*/
public static function getDashboard(int $jobId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Load job
$db->setQuery(
$db->getQuery(true)
->select('j.*')
->from($db->quoteName('#__mokosuiteconstruction_jobs', 'j'))
->where('j.id = ' . (int) $jobId)
);
$job = $db->loadObject();
if (!$job) {
throw new \RuntimeException('Job not found: ' . $jobId);
}
// Total billed (sum of pay applications)
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('total_earned') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')')
);
$totalBilled = (float) $db->loadResult();
// Total paid
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('current_due') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' = ' . $db->quote('paid'))
);
$totalPaid = (float) $db->loadResult();
// Retention held
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')')
);
$retentionHeld = (float) $db->loadResult();
// Cost to date (actual from cost codes)
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('actual_amount') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_cost_codes'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
);
$costToDate = (float) $db->loadResult();
$revisedValue = (float) $job->revised_value;
$projectedProfit = $revisedValue - $costToDate;
return (object) [
'job_id' => $jobId,
'job_number' => $job->job_number,
'title' => $job->title,
'contract_value' => (float) $job->contract_value,
'approved_changes' => (float) $job->approved_changes,
'revised_value' => $revisedValue,
'total_billed' => $totalBilled,
'total_paid' => $totalPaid,
'retention_held' => $retentionHeld,
'cost_to_date' => $costToDate,
'projected_profit' => round($projectedProfit, 2),
];
}
/**
* Get all active jobs with phase, % complete, and days remaining.
*/
public static function getActive(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('j.*')
->select('DATEDIFF(j.end_date, CURDATE()) AS days_remaining')
->from($db->quoteName('#__mokosuiteconstruction_jobs', 'j'))
->where($db->quoteName('j.phase') . ' IN ('
. $db->quote('preconstruction') . ','
. $db->quote('active') . ','
. $db->quote('punch_list') . ','
. $db->quote('closeout') . ')')
->order('j.end_date ASC')
);
$jobs = $db->loadObjectList() ?: [];
foreach ($jobs as &$job) {
$budget = (float) $job->revised_value;
if ($budget > 0) {
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('actual_amount') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_cost_codes'))
->where($db->quoteName('job_id') . ' = ' . (int) $job->id)
);
$actual = (float) $db->loadResult();
$job->pct_complete = round($actual / $budget * 100, 1);
} else {
$job->pct_complete = 0;
}
}
return $jobs;
}
/**
* Get cost report — cost codes with budget vs actual vs committed.
*/
public static function getCostReport(int $jobId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('cc.*')
->from($db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc'))
->where($db->quoteName('cc.job_id') . ' = ' . (int) $jobId)
->order('cc.division ASC, cc.section ASC')
);
$codes = $db->loadObjectList() ?: [];
foreach ($codes as &$code) {
$budget = (float) $code->budget_amount;
$actual = (float) $code->actual_amount;
$committed = (float) $code->committed_amount;
$code->variance = round($budget - $actual, 2);
$code->projected_cost = $actual + $committed;
$code->over_budget = $actual > $budget;
$code->pct_used = $budget > 0 ? round($actual / $budget * 100, 1) : 0;
}
return $codes;
}
}
@@ -0,0 +1,240 @@
<?php
namespace Moko\Plugin\System\MokoSuiteConstruction\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* AIA G702/G703 pay application management — generate applications from schedule
* of values, calculate retention, track billing progress.
*/
class PayAppHelper
{
/**
* Create a G702 pay application from the job's schedule of values (cost codes).
*/
public static function createG702(int $jobId, string $periodFrom, string $periodTo): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Load job
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteconstruction_jobs'))
->where('id = ' . (int) $jobId)
);
$job = $db->loadObject();
if (!$job) {
throw new \RuntimeException('Job not found: ' . $jobId);
}
// Get next app number
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(MAX(' . $db->quoteName('app_number') . '), 0) + 1')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
);
$appNumber = (int) $db->loadResult();
// Get previous payments total
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('current_due') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')')
);
$previousPayments = (float) $db->loadResult();
// Load cost codes as schedule of values
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteconstruction_cost_codes'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->order('division ASC, section ASC')
);
$costCodes = $db->loadObjectList() ?: [];
// Calculate totals from cost codes
$workCompleted = 0.00;
$materialsStored = 0.00;
foreach ($costCodes as $cc) {
$workCompleted += (float) $cc->actual_amount;
}
$totalEarned = $workCompleted + $materialsStored;
$retentionPct = (float) $job->retention_pct;
$retentionHeld = round($totalEarned * ($retentionPct / 100), 2);
$currentDue = round($totalEarned - $retentionHeld - $previousPayments, 2);
// Approved change orders total
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('amount') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_change_orders'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' = ' . $db->quote('approved'))
);
$changeOrdersTotal = (float) $db->loadResult();
// Create pay application header (G702)
$payApp = (object) [
'job_id' => (int) $jobId,
'app_number' => $appNumber,
'period_from' => $periodFrom,
'period_to' => $periodTo,
'contract_sum' => (float) $job->contract_value,
'change_orders_total' => $changeOrdersTotal,
'revised_contract' => (float) $job->revised_value,
'work_completed' => round($workCompleted, 2),
'materials_stored' => round($materialsStored, 2),
'total_earned' => round($totalEarned, 2),
'retention_held' => $retentionHeld,
'previous_payments' => round($previousPayments, 2),
'current_due' => $currentDue,
'status' => 'draft',
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuiteconstruction_pay_applications', $payApp, 'id');
// Create G703 line items from cost codes
// Get previous work per cost code
foreach ($costCodes as $cc) {
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('current_work') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_app_lines', 'pal'))
->join('INNER', $db->quoteName('#__mokosuiteconstruction_pay_applications', 'pa')
. ' ON pa.id = pal.pay_app_id')
->where($db->quoteName('pal.cost_code_id') . ' = ' . (int) $cc->id)
->where($db->quoteName('pa.status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')')
);
$previousWork = (float) $db->loadResult();
$scheduledValue = (float) $cc->budget_amount;
$currentWork = (float) $cc->actual_amount - $previousWork;
$totalCompleted = $previousWork + max($currentWork, 0);
$pctComplete = $scheduledValue > 0 ? round($totalCompleted / $scheduledValue * 100, 2) : 0;
$balanceToFinish = round($scheduledValue - $totalCompleted, 2);
$line = (object) [
'pay_app_id' => (int) $payApp->id,
'cost_code_id' => (int) $cc->id,
'scheduled_value' => $scheduledValue,
'previous_work' => round($previousWork, 2),
'current_work' => round(max($currentWork, 0), 2),
'materials_stored' => 0.00,
'total_completed' => round($totalCompleted, 2),
'pct_complete' => $pctComplete,
'balance_to_finish' => max($balanceToFinish, 0),
];
$db->insertObject('#__mokosuiteconstruction_pay_app_lines', $line, 'id');
}
return $payApp;
}
/**
* Get the G703 schedule of values — line items with cost codes, % complete, balance to finish.
*/
public static function getScheduleOfValues(int $jobId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Get the latest pay application for this job
$db->setQuery(
$db->getQuery(true)
->select('id')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->order('app_number DESC')
->setLimit(1)
);
$latestPayAppId = (int) $db->loadResult();
if (!$latestPayAppId) {
// No pay app yet — return cost codes as base schedule
$db->setQuery(
$db->getQuery(true)
->select('cc.id AS cost_code_id')
->select('cc.division, cc.section, cc.title')
->select('cc.budget_amount AS scheduled_value')
->select('0.00 AS previous_work')
->select('0.00 AS current_work')
->select('0.00 AS materials_stored')
->select('0.00 AS total_completed')
->select('0.00 AS pct_complete')
->select('cc.budget_amount AS balance_to_finish')
->from($db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc'))
->where($db->quoteName('cc.job_id') . ' = ' . (int) $jobId)
->order('cc.division ASC, cc.section ASC')
);
return $db->loadObjectList() ?: [];
}
$db->setQuery(
$db->getQuery(true)
->select('pal.*')
->select('cc.division, cc.section, cc.title')
->from($db->quoteName('#__mokosuiteconstruction_pay_app_lines', 'pal'))
->join('LEFT', $db->quoteName('#__mokosuiteconstruction_cost_codes', 'cc')
. ' ON cc.id = pal.cost_code_id')
->where($db->quoteName('pal.pay_app_id') . ' = ' . (int) $latestPayAppId)
->order('cc.division ASC, cc.section ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Calculate retention summary for a job.
*/
public static function calculateRetention(int $jobId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('total_earned') . '), 0) AS total_earned')
->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0) AS retention_held')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' IN (' . $db->quote('approved') . ',' . $db->quote('paid') . ')')
);
$totals = $db->loadObject();
$totalEarned = (float) $totals->total_earned;
$retentionHeld = (float) $totals->retention_held;
// Retention released = retention from paid apps that have been fully released
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(SUM(' . $db->quoteName('retention_held') . '), 0)')
->from($db->quoteName('#__mokosuiteconstruction_pay_applications'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('status') . ' = ' . $db->quote('paid'))
->where($db->quoteName('retention_held') . ' = 0')
);
$retentionReleased = (float) $db->loadResult();
return (object) [
'total_earned' => round($totalEarned, 2),
'retention_held' => round($retentionHeld, 2),
'retention_released' => round($retentionReleased, 2),
'retention_balance' => round($retentionHeld - $retentionReleased, 2),
];
}
}
@@ -0,0 +1,141 @@
<?php
namespace Moko\Plugin\System\MokoSuiteConstruction\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* RFI management — create, respond, open RFIs with aging, ball-in-court grouping.
*/
class RfiHelper
{
/**
* Create an RFI for a job.
*/
public static function create(int $jobId, string $subject, string $question, string $specSection = ''): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Get next RFI number for this job
$db->setQuery(
$db->getQuery(true)
->select('COALESCE(MAX(' . $db->quoteName('rfi_number') . '), 0) + 1')
->from($db->quoteName('#__mokosuiteconstruction_rfis'))
->where($db->quoteName('job_id') . ' = ' . (int) $jobId)
);
$rfiNumber = (int) $db->loadResult();
$rfi = (object) [
'job_id' => (int) $jobId,
'rfi_number' => $rfiNumber,
'subject' => $subject,
'question' => $question,
'spec_section' => $specSection,
'drawing_ref' => '',
'ball_in_court' => '',
'status' => 'open',
'submitted_by' => Factory::getApplication()->getIdentity()->id,
'submitted_at' => $now,
'created' => $now,
];
$db->insertObject('#__mokosuiteconstruction_rfis', $rfi, 'id');
return $rfi;
}
/**
* Record a response to an RFI and set answered_at timestamp.
*/
public static function respond(int $rfiId, string $answer): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Verify RFI exists
$db->setQuery(
$db->getQuery(true)
->select('id')
->from($db->quoteName('#__mokosuiteconstruction_rfis'))
->where('id = ' . (int) $rfiId)
);
if (!$db->loadResult()) {
throw new \RuntimeException('RFI not found: ' . $rfiId);
}
$update = (object) [
'id' => (int) $rfiId,
'answer' => $answer,
'status' => 'answered',
'answered_by' => Factory::getApplication()->getIdentity()->id,
'answered_at' => $now,
];
$db->updateObject('#__mokosuiteconstruction_rfis', $update, 'id');
// Return refreshed record
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuiteconstruction_rfis'))
->where('id = ' . (int) $rfiId)
);
return $db->loadObject();
}
/**
* Get open RFIs for a job with days aging (DATEDIFF from submitted_at).
*/
public static function getOpen(int $jobId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select('r.*')
->select('DATEDIFF(CURDATE(), r.submitted_at) AS days_aging')
->from($db->quoteName('#__mokosuiteconstruction_rfis', 'r'))
->where($db->quoteName('r.job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('r.status') . ' = ' . $db->quote('open'))
->order('r.submitted_at ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get RFIs grouped by ball_in_court party for a job.
*/
public static function getBallInCourt(int $jobId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('r.ball_in_court'))
->select('COUNT(*) AS rfi_count')
->select('SUM(CASE WHEN r.status = ' . $db->quote('open')
. ' THEN 1 ELSE 0 END) AS open_count')
->select('AVG(DATEDIFF(CURDATE(), r.submitted_at)) AS avg_days_aging')
->from($db->quoteName('#__mokosuiteconstruction_rfis', 'r'))
->where($db->quoteName('r.job_id') . ' = ' . (int) $jobId)
->where($db->quoteName('r.ball_in_court') . ' != ' . $db->quote(''))
->group($db->quoteName('r.ball_in_court'))
->order('open_count DESC')
);
$groups = $db->loadObjectList() ?: [];
foreach ($groups as &$group) {
$group->avg_days_aging = round((float) $group->avg_days_aging, 1);
}
return $groups;
}
}