Files
MokoSuiteConstruction/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.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

166 lines
4.6 KiB
PHP

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