From 71e9bcff240158564f23a5bf5a6ae8fcb1e51ca2 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 11:48:27 -0500 Subject: [PATCH] feat: add 5 helpers (Job, Bid, ChangeOrder, Rfi, PayApp) --- .../src/Helper/BidHelper.php | 135 ++++++++++ .../src/Helper/ChangeOrderHelper.php | 165 ++++++++++++ .../src/Helper/JobHelper.php | 214 ++++++++++++++++ .../src/Helper/PayAppHelper.php | 240 ++++++++++++++++++ .../src/Helper/RfiHelper.php | 141 ++++++++++ 5 files changed, 895 insertions(+) create mode 100644 source/packages/plg_system_mokosuiteconstruction/src/Helper/BidHelper.php create mode 100644 source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php create mode 100644 source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php create mode 100644 source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php create mode 100644 source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/BidHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/BidHelper.php new file mode 100644 index 0000000..8f9cef0 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/BidHelper.php @@ -0,0 +1,135 @@ +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, + ]; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php new file mode 100644 index 0000000..d7a52fa --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/ChangeOrderHelper.php @@ -0,0 +1,165 @@ +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; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php new file mode 100644 index 0000000..0605314 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/JobHelper.php @@ -0,0 +1,214 @@ +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; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php new file mode 100644 index 0000000..be3d7e2 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/PayAppHelper.php @@ -0,0 +1,240 @@ +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), + ]; + } +} diff --git a/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php b/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php new file mode 100644 index 0000000..6b85596 --- /dev/null +++ b/source/packages/plg_system_mokosuiteconstruction/src/Helper/RfiHelper.php @@ -0,0 +1,141 @@ +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; + } +}