Files
MokoSuiteConstruction/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php
T
Jonathan Miller 71e9bcff24
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
feat: add 5 helpers (Job, Bid, ChangeOrder, Rfi, PayApp)
2026-06-23 11:48:30 -05:00

215 lines
6.9 KiB
PHP

<?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;
}
}