215 lines
6.9 KiB
PHP
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;
|
|
}
|
|
}
|