Add InvoiceHelper — generate CRM invoices from completed work orders (labor hours + parts), batch invoicing

This commit is contained in:
Jonathan Miller
2026-06-18 08:24:57 -05:00
parent d2dc55539f
commit 71b5166251
@@ -0,0 +1,146 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Field service invoicing — generate invoices from completed work orders with labor + parts.
*/
class InvoiceHelper
{
/**
* Generate a CRM invoice from a completed work order.
*/
public static function generateFromWorkOrder(int $woId): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Load work order
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->where('wo.id = ' . $woId));
$wo = $db->loadObject();
if (!$wo) throw new \RuntimeException('Work order not found: ' . $woId);
$params = Factory::getApplication()->getParams('com_mokosuitefield');
$laborRate = (float) $params->get('default_labor_rate', 85.00);
// Get time entries
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitefield_time_entries')
->where('wo_id = ' . $woId));
$timeEntries = $db->loadObjectList() ?: [];
$totalHours = 0;
foreach ($timeEntries as $te) {
$totalHours += (float) $te->hours;
}
// Get parts used
$db->setQuery($db->getQuery(true)
->select('wi.*, p.title AS part_name, p.price')
->from($db->quoteName('#__mokosuitefield_wo_items', 'wi'))
->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = wi.product_id')
->where('wi.wo_id = ' . $woId));
$parts = $db->loadObjectList() ?: [];
$partsTotal = 0;
foreach ($parts as $part) {
$partsTotal += (float) ($part->price ?? 0) * (int) $part->quantity;
}
$laborTotal = round($totalHours * $laborRate, 2);
$subtotal = $laborTotal + $partsTotal;
$taxRate = (float) $params->get('default_tax_rate', 0.07);
$tax = round($subtotal * $taxRate, 2);
$total = $subtotal + $tax;
// Create CRM invoice
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_crm_invoices'))->loadResult() + 1;
$invoice = (object) [
'contact_id' => $wo->contact_id,
'invoice_number' => 'FSI-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
'type' => 'standard',
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'balance_due' => $total,
'status' => 'draft',
'due_date' => date('Y-m-d', strtotime('+30 days')),
'notes' => 'Field Service Invoice for WO #' . ($wo->wo_number ?? $woId),
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuite_crm_invoices', $invoice, 'id');
$invoiceId = (int) $invoice->id;
// Add labor line item
if ($totalHours > 0) {
$db->insertObject('#__mokosuite_crm_invoice_items', (object) [
'invoice_id' => $invoiceId,
'description' => 'Labor — ' . number_format($totalHours, 1) . ' hours @ $' . number_format($laborRate, 2) . '/hr',
'quantity' => $totalHours,
'unit_price' => $laborRate,
'line_total' => $laborTotal,
]);
}
// Add parts line items
foreach ($parts as $part) {
$db->insertObject('#__mokosuite_crm_invoice_items', (object) [
'invoice_id' => $invoiceId,
'product_id' => $part->product_id,
'description' => $part->part_name ?? 'Part',
'quantity' => $part->quantity,
'unit_price' => $part->price ?? 0,
'line_total' => round((float) ($part->price ?? 0) * (int) $part->quantity, 2),
]);
}
// Link invoice to work order
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_work_orders')
->set('invoice_id = ' . $invoiceId)
->where('id = ' . $woId));
$db->execute();
return $invoiceId;
}
/**
* Batch generate invoices for all completed, uninvoiced work orders.
*/
public static function batchGenerate(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('id')
->from('#__mokosuitefield_work_orders')
->where($db->quoteName('status') . ' = ' . $db->quote('completed'))
->where('invoice_id IS NULL OR invoice_id = 0')
->order('completed_at ASC'));
$woIds = $db->loadColumn() ?: [];
$results = [];
foreach ($woIds as $woId) {
try {
$invoiceId = self::generateFromWorkOrder((int) $woId);
$results[] = ['wo_id' => $woId, 'invoice_id' => $invoiceId, 'success' => true];
} catch (\Throwable $e) {
$results[] = ['wo_id' => $woId, 'error' => $e->getMessage(), 'success' => false];
}
}
return $results;
}
}