feat: add 5 helpers (Job, Bid, ChangeOrder, Rfi, PayApp)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user