166 lines
4.6 KiB
PHP
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;
|
|
}
|
|
}
|