diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0388e51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# MokoSuite standard ignores +.claude/ +.mcp.json +TODO.md +vendor/ +node_modules/ +.env +*.min.css +*.min.js +.DS_Store +Thumbs.db +*.ppk diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d37e676 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ + + +# Changelog + +## [Unreleased] + +### Added +- **Repository** -- initial repo creation with scaffolding +- **System Plugin** -- Extension class, service provider, 6 helpers +- **SQL Schema** -- 8 tables: vehicles, drivers, zones, fares, rides, ratings, dispatch, shifts +- **Admin Component** -- 6 views: dashboard, rides, vehicles, drivers, zones, fares +- **Webservices Plugin** -- 7 API routes: rides, vehicles, drivers, zones, fares, dispatch, ratings +- **Configuration** -- 16 settings across general, dispatch, fares, drivers, booking +- **Access Control** -- 21 permissions for granular role-based access diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..713e6a2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# MokoSuiteTaxi + +Ride-hailing, dispatch, fleet management, fare zones, and driver scheduling for Joomla 6. + +## Quick Reference + +| Field | Value | +|---|---| +| **Package** | `pkg_mokosuitetaxi` | +| **Layer** | 2 (requires: Client, CRM) | +| **Language** | PHP 8.3+ | +| **Branch** | develop on `dev`, merge to `main` (protected) | +| **Wiki** | [MokoSuiteTaxi Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteTaxi/wiki) | + +## Architecture + +Joomla **package** -- Layer 2 add-on. CRM contacts as riders/drivers, zone-based dispatch with surge pricing. + +## Rules + +- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js` +- **Attribution**: `Authored-by: Moko Consulting` +- **Workflow directory**: `.mokogitea/` +- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoCLI/wiki) +- **Changelog**: `[Unreleased]` only -- release system assigns versions + +## Coding Standards + +- PHP 8.3+ / Joomla 6 patterns +- `$this->getDatabase()` in models, `Factory::getContainer()->get(DatabaseInterface::class)` in helpers +- `Factory::getApplication()->getIdentity()` for user +- FOR UPDATE locking on ride acceptance/dispatch to prevent race conditions diff --git a/README.md b/README.md index eeff7b8..21a8ded 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ -# MokoSuiteTaxi + -MokoSuite Taxi — ride-hailing, dispatch, fleet management, fare zones, driver scheduling for Joomla 6 \ No newline at end of file +# MokoSuite Taxi + +Ride-hailing, dispatch, fleet management, fare zones, and driver scheduling module for MokoSuite on Joomla 6. + +## Overview + +MokoSuiteTaxi is a Layer 2 module in the MokoSuite platform, building on MokoSuiteClient (Layer 0) and MokoSuiteCRM (Layer 1) to provide complete taxi and ride-hailing operations management. + +## Features + +- **Ride Management** -- on-demand, scheduled, airport, and corporate ride types +- **Dispatch Engine** -- auto-dispatch with zone-aware driver matching and FOR UPDATE locking +- **Fleet Management** -- vehicle tracking, insurance/inspection expiry alerts, maintenance status +- **Driver Management** -- profiles linked to CRM contacts, approval workflow, background checks +- **Fare Zones** -- GeoJSON polygon boundaries with radius fallback, zone-based pricing +- **Dynamic Pricing** -- surge multiplier based on demand/supply ratio, night surcharges, peak hours +- **Ratings** -- bidirectional rider/driver ratings with feedback tags +- **Shift Scheduling** -- driver shift management with earnings tracking + +## Requirements + +- Joomla 6.x +- PHP 8.3+ +- MokoSuiteClient (Layer 0) +- MokoSuiteCRM (Layer 1) + +## Installation + +Install via Joomla Extension Manager using the package file `pkg_mokosuitetaxi.zip`. + +## License + +GNU General Public License v3.0 or later -- see [LICENSE](LICENSE). + +## Links + +- [Documentation](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteTaxi/wiki) +- [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteTaxi/issues) +- [MokoSuite Platform](https://mokoconsulting.tech) diff --git a/source/packages/com_mokosuitetaxi/admin/access.xml b/source/packages/com_mokosuitetaxi/admin/access.xml new file mode 100644 index 0000000..5ae7eda --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/access.xml @@ -0,0 +1,26 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/source/packages/com_mokosuitetaxi/admin/config.xml b/source/packages/com_mokosuitetaxi/admin/config.xml new file mode 100644 index 0000000..860a4d8 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/config.xml @@ -0,0 +1,51 @@ + + +
+ + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + + +
+
+ + + + + + +
+
+ + + + + + + + + +
+
diff --git a/source/packages/com_mokosuitetaxi/admin/services/provider.php b/source/packages/com_mokosuitetaxi/admin/services/provider.php new file mode 100644 index 0000000..6c33848 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/services/provider.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\ComponentInterface; +use Joomla\CMS\Extension\MVCComponent; +use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +return new class implements ServiceProviderInterface { + public function register(Container $container): void { + $container->set(ComponentInterface::class, function (Container $container) { + $c = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $c->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $c; + }); + } +}; diff --git a/source/packages/com_mokosuitetaxi/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitetaxi/admin/src/Controller/DisplayController.php new file mode 100644 index 0000000..dc995f1 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/Controller/DisplayController.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\BaseController; + +class DisplayController extends BaseController +{ + protected $default_view = 'taxidashboard'; +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/Model/TaxiDashboardModel.php b/source/packages/com_mokosuitetaxi/admin/src/Model/TaxiDashboardModel.php new file mode 100644 index 0000000..04e69b5 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/Model/TaxiDashboardModel.php @@ -0,0 +1,17 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class TaxiDashboardModel extends BaseDatabaseModel +{ +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDashboard/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDashboard/HtmlView.php new file mode 100644 index 0000000..4c7c3c9 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDashboard/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiDashboard; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Dashboard'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDrivers/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDrivers/HtmlView.php new file mode 100644 index 0000000..a0f6d70 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiDrivers/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiDrivers; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Drivers'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiFares/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiFares/HtmlView.php new file mode 100644 index 0000000..0914bda --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiFares/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiFares; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Fare Rules'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiRides/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiRides/HtmlView.php new file mode 100644 index 0000000..127b07c --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiRides/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiRides; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Rides'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiVehicles/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiVehicles/HtmlView.php new file mode 100644 index 0000000..054e4b1 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiVehicles/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiVehicles; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Vehicles'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/src/View/TaxiZones/HtmlView.php b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiZones/HtmlView.php new file mode 100644 index 0000000..3ad6d82 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/src/View/TaxiZones/HtmlView.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Component\MokoSuiteTaxi\Administrator\View\TaxiZones; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Taxi - Zones'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxidashboard/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxidashboard/default.php new file mode 100644 index 0000000..e80e602 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxidashboard/default.php @@ -0,0 +1 @@ +

Dashboard

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxidrivers/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxidrivers/default.php new file mode 100644 index 0000000..9668d20 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxidrivers/default.php @@ -0,0 +1 @@ +

Drivers

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxifares/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxifares/default.php new file mode 100644 index 0000000..c6883f7 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxifares/default.php @@ -0,0 +1 @@ +

Fares

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxirides/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxirides/default.php new file mode 100644 index 0000000..018e753 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxirides/default.php @@ -0,0 +1 @@ +

Rides

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxivehicles/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxivehicles/default.php new file mode 100644 index 0000000..70f3d47 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxivehicles/default.php @@ -0,0 +1 @@ +

Vehicles

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/admin/tmpl/taxizones/default.php b/source/packages/com_mokosuitetaxi/admin/tmpl/taxizones/default.php new file mode 100644 index 0000000..597a753 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/admin/tmpl/taxizones/default.php @@ -0,0 +1 @@ +

Zones

Coming soon.

diff --git a/source/packages/com_mokosuitetaxi/mokosuitetaxi.xml b/source/packages/com_mokosuitetaxi/mokosuitetaxi.xml new file mode 100644 index 0000000..bd0bfd6 --- /dev/null +++ b/source/packages/com_mokosuitetaxi/mokosuitetaxi.xml @@ -0,0 +1,14 @@ + + + MokoSuite Taxi + Moko Consulting + 2026-06-27 + Copyright (C) 2026 Moko Consulting. + GPL-3.0-or-later + 06.00.00 + Moko\Component\MokoSuiteTaxi + + servicessrctmpl + COM_MOKOSUITETAXI + + diff --git a/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.ini b/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.ini new file mode 100644 index 0000000..6c013ee --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITETAXI="System - MokoSuite Taxi" +PLG_SYSTEM_MOKOSUITETAXI_DESC="Ride-hailing, dispatch, fleet management, fare zones, and driver scheduling" diff --git a/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.sys.ini b/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.sys.ini new file mode 100644 index 0000000..6c013ee --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/language/en-GB/plg_system_mokosuitetaxi.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITETAXI="System - MokoSuite Taxi" +PLG_SYSTEM_MOKOSUITETAXI_DESC="Ride-hailing, dispatch, fleet management, fare zones, and driver scheduling" diff --git a/source/packages/plg_system_mokosuitetaxi/mokosuitetaxi.xml b/source/packages/plg_system_mokosuitetaxi/mokosuitetaxi.xml new file mode 100644 index 0000000..bc6ab3f --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/mokosuitetaxi.xml @@ -0,0 +1,67 @@ + + + System - MokoSuite Taxi + mokosuitetaxi + Moko Consulting + 2026-06-27 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 06.00.00 + 8.3 + PLG_SYSTEM_MOKOSUITETAXI_DESC + Moko\Plugin\System\MokoSuiteTaxi + + src + services + language + sql + + + en-GB/plg_system_mokosuitetaxi.ini + en-GB/plg_system_mokosuitetaxi.sys.ini + + sql/install.mysql.sql + sql/uninstall.mysql.sql + + +
+ + + + + + + + + + +
+
+ + + + + + + + + +
+
+ + + + +
+
+ + + + + +
+
+
+
diff --git a/source/packages/plg_system_mokosuitetaxi/services/provider.php b/source/packages/plg_system_mokosuitetaxi/services/provider.php new file mode 100644 index 0000000..130e71f --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/services/provider.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Moko\Plugin\System\MokoSuiteTaxi\Extension\Taxi; + +return new class implements ServiceProviderInterface +{ + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Taxi($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuitetaxi')); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuitetaxi/sql/install.mysql.sql b/source/packages/plg_system_mokosuitetaxi/sql/install.mysql.sql new file mode 100644 index 0000000..d3c40b2 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/sql/install.mysql.sql @@ -0,0 +1,216 @@ +-- +-- MokoSuite Taxi Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `plate_number` VARCHAR(20) NOT NULL, + `vin` VARCHAR(17) NOT NULL DEFAULT '', + `make` VARCHAR(100) NOT NULL, + `model` VARCHAR(100) NOT NULL, + `year` SMALLINT UNSIGNED NOT NULL, + `color` VARCHAR(50) NOT NULL DEFAULT '', + `vehicle_type` ENUM('sedan','suv','van','luxury','minibus','motorcycle') NOT NULL DEFAULT 'sedan', + `capacity` TINYINT UNSIGNED NOT NULL DEFAULT 4, + `fuel_type` ENUM('gasoline','diesel','electric','hybrid') NOT NULL DEFAULT 'gasoline', + `status` ENUM('available','in_service','maintenance','retired') NOT NULL DEFAULT 'available', + `insurance_expiry` DATE DEFAULT NULL, + `inspection_expiry` DATE DEFAULT NULL, + `odometer_km` INT UNSIGNED NOT NULL DEFAULT 0, + `photo` VARCHAR(500) NOT NULL DEFAULT '', + `notes` TEXT, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_plate` (`plate_number`), + KEY `idx_status` (`status`), + KEY `idx_type` (`vehicle_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_drivers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT DEFAULT NULL COMMENT 'FK to #__contact_details', + `name` VARCHAR(255) NOT NULL, + `phone` VARCHAR(50) NOT NULL DEFAULT '', + `email` VARCHAR(255) NOT NULL DEFAULT '', + `license_number` VARCHAR(50) NOT NULL, + `license_expiry` DATE DEFAULT NULL, + `license_class` VARCHAR(10) NOT NULL DEFAULT '', + `vehicle_id` INT UNSIGNED DEFAULT NULL, + `status` ENUM('active','inactive','suspended','pending_approval') NOT NULL DEFAULT 'pending_approval', + `rating` DECIMAL(3,2) NOT NULL DEFAULT 5.00, + `total_rides` INT UNSIGNED NOT NULL DEFAULT 0, + `total_earnings` DECIMAL(12,2) NOT NULL DEFAULT 0.00, + `commission_rate` DECIMAL(5,2) DEFAULT NULL COMMENT 'Override platform default', + `photo` VARCHAR(500) NOT NULL DEFAULT '', + `background_check_date` DATE DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_license` (`license_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_vehicle` (`vehicle_id`), + KEY `idx_status` (`status`), + KEY `idx_rating` (`rating`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_zones` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` TEXT, + `zone_type` ENUM('pickup','dropoff','service','surge','restricted') NOT NULL DEFAULT 'service', + `boundary_geojson` JSON COMMENT 'GeoJSON polygon defining zone boundary', + `center_lat` DECIMAL(10,7) DEFAULT NULL, + `center_lng` DECIMAL(10,7) DEFAULT NULL, + `radius_km` DECIMAL(8,2) DEFAULT NULL COMMENT 'Circular zone fallback if no polygon', + `fare_multiplier` DECIMAL(4,2) NOT NULL DEFAULT 1.00, + `active_hours_start` TIME DEFAULT NULL, + `active_hours_end` TIME DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `ordering` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`zone_type`), + KEY `idx_center` (`center_lat`, `center_lng`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_fares` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `vehicle_type` ENUM('sedan','suv','van','luxury','minibus','motorcycle','all') NOT NULL DEFAULT 'all', + `zone_id` INT UNSIGNED DEFAULT NULL COMMENT 'Zone-specific pricing, NULL = default', + `base_fare` DECIMAL(10,2) NOT NULL DEFAULT 2.50, + `per_km` DECIMAL(10,2) NOT NULL DEFAULT 1.50, + `per_minute` DECIMAL(10,2) NOT NULL DEFAULT 0.25, + `minimum_fare` DECIMAL(10,2) NOT NULL DEFAULT 5.00, + `booking_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `peak_multiplier` DECIMAL(4,2) NOT NULL DEFAULT 1.00, + `peak_hours_start` TIME DEFAULT NULL, + `peak_hours_end` TIME DEFAULT NULL, + `night_surcharge` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `airport_surcharge` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `effective_from` DATE DEFAULT NULL, + `effective_to` DATE DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_vehicle_type` (`vehicle_type`), + KEY `idx_zone` (`zone_id`), + KEY `idx_effective` (`effective_from`, `effective_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_rides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ride_ref` VARCHAR(20) NOT NULL, + `rider_contact_id` INT DEFAULT NULL COMMENT 'FK to #__contact_details', + `rider_name` VARCHAR(255) NOT NULL, + `rider_phone` VARCHAR(50) NOT NULL DEFAULT '', + `driver_id` INT UNSIGNED DEFAULT NULL, + `vehicle_id` INT UNSIGNED DEFAULT NULL, + `status` ENUM('requested','dispatched','accepted','arriving','in_progress','completed','cancelled','no_driver') NOT NULL DEFAULT 'requested', + `ride_type` ENUM('on_demand','scheduled','airport','corporate') NOT NULL DEFAULT 'on_demand', + `vehicle_type_requested` ENUM('sedan','suv','van','luxury','minibus','motorcycle','any') NOT NULL DEFAULT 'any', + `pickup_address` VARCHAR(500) NOT NULL, + `pickup_lat` DECIMAL(10,7) DEFAULT NULL, + `pickup_lng` DECIMAL(10,7) DEFAULT NULL, + `dropoff_address` VARCHAR(500) NOT NULL DEFAULT '', + `dropoff_lat` DECIMAL(10,7) DEFAULT NULL, + `dropoff_lng` DECIMAL(10,7) DEFAULT NULL, + `pickup_zone_id` INT UNSIGNED DEFAULT NULL, + `dropoff_zone_id` INT UNSIGNED DEFAULT NULL, + `scheduled_at` DATETIME DEFAULT NULL, + `dispatched_at` DATETIME DEFAULT NULL, + `accepted_at` DATETIME DEFAULT NULL, + `arrived_at` DATETIME DEFAULT NULL, + `started_at` DATETIME DEFAULT NULL, + `completed_at` DATETIME DEFAULT NULL, + `cancelled_at` DATETIME DEFAULT NULL, + `cancelled_by` ENUM('rider','driver','system') DEFAULT NULL, + `cancel_reason` VARCHAR(500) NOT NULL DEFAULT '', + `distance_km` DECIMAL(10,2) DEFAULT NULL, + `duration_minutes` DECIMAL(10,2) DEFAULT NULL, + `fare_id` INT UNSIGNED DEFAULT NULL, + `base_fare` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `distance_charge` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `time_charge` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `surge_multiplier` DECIMAL(4,2) NOT NULL DEFAULT 1.00, + `surcharges` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `discount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total_fare` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `driver_payout` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `platform_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `payment_method` ENUM('cash','card','wallet','corporate') NOT NULL DEFAULT 'cash', + `payment_status` ENUM('pending','paid','refunded') NOT NULL DEFAULT 'pending', + `passenger_count` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `notes` TEXT, + `route_polyline` TEXT COMMENT 'Encoded polyline of actual route', + `created` DATETIME NOT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_ref` (`ride_ref`), + KEY `idx_rider` (`rider_contact_id`), + KEY `idx_driver` (`driver_id`), + KEY `idx_vehicle` (`vehicle_id`), + KEY `idx_status` (`status`), + KEY `idx_type` (`ride_type`), + KEY `idx_created` (`created`), + KEY `idx_scheduled` (`scheduled_at`), + KEY `idx_pickup_zone` (`pickup_zone_id`), + KEY `idx_dropoff_zone` (`dropoff_zone_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_ratings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ride_id` INT UNSIGNED NOT NULL, + `rated_by` ENUM('rider','driver') NOT NULL, + `rating` TINYINT UNSIGNED NOT NULL COMMENT '1-5 stars', + `comment` TEXT, + `tags` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Comma-separated feedback tags', + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_ride_rater` (`ride_id`, `rated_by`), + KEY `idx_rating` (`rating`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_dispatch` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ride_id` INT UNSIGNED NOT NULL, + `driver_id` INT UNSIGNED NOT NULL, + `status` ENUM('offered','accepted','rejected','expired') NOT NULL DEFAULT 'offered', + `offered_at` DATETIME NOT NULL, + `responded_at` DATETIME DEFAULT NULL, + `distance_to_pickup_km` DECIMAL(10,2) DEFAULT NULL, + `eta_minutes` DECIMAL(10,2) DEFAULT NULL, + `attempt_number` TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_ride` (`ride_id`), + KEY `idx_driver` (`driver_id`), + KEY `idx_status` (`status`), + KEY `idx_offered` (`offered_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitetaxi_shifts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `driver_id` INT UNSIGNED NOT NULL, + `vehicle_id` INT UNSIGNED DEFAULT NULL, + `start_time` DATETIME NOT NULL, + `end_time` DATETIME DEFAULT NULL, + `status` ENUM('scheduled','active','completed','cancelled') NOT NULL DEFAULT 'scheduled', + `start_lat` DECIMAL(10,7) DEFAULT NULL, + `start_lng` DECIMAL(10,7) DEFAULT NULL, + `end_lat` DECIMAL(10,7) DEFAULT NULL, + `end_lng` DECIMAL(10,7) DEFAULT NULL, + `total_rides` INT UNSIGNED NOT NULL DEFAULT 0, + `total_distance_km` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total_earnings` DECIMAL(12,2) NOT NULL DEFAULT 0.00, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_driver` (`driver_id`), + KEY `idx_vehicle` (`vehicle_id`), + KEY `idx_times` (`start_time`, `end_time`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuitetaxi/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitetaxi/sql/uninstall.mysql.sql new file mode 100644 index 0000000..6e0b8c6 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/sql/uninstall.mysql.sql @@ -0,0 +1,12 @@ +-- +-- MokoSuite Taxi — Uninstall +-- + +DROP TABLE IF EXISTS `#__mokosuitetaxi_shifts`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_dispatch`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_ratings`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_rides`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_fares`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_zones`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_drivers`; +DROP TABLE IF EXISTS `#__mokosuitetaxi_vehicles`; diff --git a/source/packages/plg_system_mokosuitetaxi/src/Extension/Taxi.php b/source/packages/plg_system_mokosuitetaxi/src/Extension/Taxi.php new file mode 100644 index 0000000..55bf0e8 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Extension/Taxi.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Plugin\System\MokoSuiteTaxi\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\SubscriberInterface; + +class Taxi extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return []; + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/DispatchHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/DispatchHelper.php new file mode 100644 index 0000000..0312d68 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/DispatchHelper.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class DispatchHelper +{ + public static function findNearbyDrivers( + float $lat, + float $lng, + float $radiusKm = 10.0, + string $vehicleType = 'any', + int $limit = 10 + ): array { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + 'd.id', 'd.name', 'd.rating', 'd.vehicle_id', + 'v.plate_number', 'v.vehicle_type', 'v.capacity', + 's.start_lat AS lat', 's.start_lng AS lng', + ]) + ->from($db->quoteName('#__mokosuitetaxi_drivers', 'd')) + ->join('INNER', $db->quoteName('#__mokosuitetaxi_shifts', 's') + . ' ON s.driver_id = d.id AND s.status = ' . $db->quote('active')) + ->join('LEFT', $db->quoteName('#__mokosuitetaxi_vehicles', 'v') + . ' ON v.id = d.vehicle_id') + ->where($db->quoteName('d.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('d.published') . ' = 1') + ->where('s.start_lat IS NOT NULL') + ->where('s.start_lng IS NOT NULL') + ->order('d.rating DESC') + ->setLimit($limit); + + if ($vehicleType !== 'any') { + $query->where($db->quoteName('v.vehicle_type') . ' = ' . $db->quote($vehicleType)); + } + + $db->setQuery($query); + $drivers = $db->loadObjectList() ?: []; + + $nearby = []; + + foreach ($drivers as $driver) { + $distance = TaxiHelper::haversineDistance($lat, $lng, (float) $driver->lat, (float) $driver->lng); + + if ($distance <= $radiusKm) { + $driver->distance_km = round($distance, 2); + $driver->eta_minutes = round($distance * 2.5, 1); + $nearby[] = $driver; + } + } + + usort($nearby, fn($a, $b) => $a->distance_km <=> $b->distance_km); + + return $nearby; + } + + public static function dispatchRide(int $rideId, int $driverId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // FOR UPDATE lock to prevent race condition on ride acceptance + $db->transactionStart(); + + try { + $db->setQuery('SELECT id, status FROM ' . $db->quoteName('#__mokosuitetaxi_rides') + . ' WHERE id = ' . (int) $rideId . ' FOR UPDATE'); + $ride = $db->loadObject(); + + if (!$ride || !in_array($ride->status, ['requested', 'dispatched'], true)) { + $db->transactionRollback(); + throw new \RuntimeException('Ride is no longer available for dispatch'); + } + + $attempt = 1; + $db->setQuery($db->getQuery(true) + ->select('COALESCE(MAX(attempt_number), 0)') + ->from('#__mokosuitetaxi_dispatch') + ->where('ride_id = ' . (int) $rideId)); + $attempt = (int) $db->loadResult() + 1; + + $dispatch = (object) [ + 'ride_id' => $rideId, + 'driver_id' => $driverId, + 'status' => 'offered', + 'offered_at' => $now, + 'attempt_number' => $attempt, + ]; + + $db->insertObject('#__mokosuitetaxi_dispatch', $dispatch, 'id'); + + $update = (object) [ + 'id' => $rideId, + 'status' => 'dispatched', + 'driver_id' => $driverId, + 'dispatched_at' => $now, + ]; + $db->updateObject('#__mokosuitetaxi_rides', $update, 'id'); + + $db->transactionCommit(); + + return $dispatch; + } catch (\Throwable $e) { + $db->transactionRollback(); + throw $e; + } + } + + public static function acceptRide(int $rideId, int $driverId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->transactionStart(); + + try { + $db->setQuery('SELECT id, status, driver_id FROM ' . $db->quoteName('#__mokosuitetaxi_rides') + . ' WHERE id = ' . (int) $rideId . ' FOR UPDATE'); + $ride = $db->loadObject(); + + if (!$ride || $ride->status !== 'dispatched' || (int) $ride->driver_id !== $driverId) { + $db->transactionRollback(); + return false; + } + + $db->updateObject('#__mokosuitetaxi_rides', (object) [ + 'id' => $rideId, + 'status' => 'accepted', + 'accepted_at' => $now, + ], 'id'); + + $query = $db->getQuery(true) + ->update('#__mokosuitetaxi_dispatch') + ->set($db->quoteName('status') . ' = ' . $db->quote('accepted')) + ->set($db->quoteName('responded_at') . ' = ' . $db->quote($now)) + ->where('ride_id = ' . (int) $rideId) + ->where('driver_id = ' . (int) $driverId) + ->where($db->quoteName('status') . ' = ' . $db->quote('offered')); + $db->setQuery($query)->execute(); + + $db->transactionCommit(); + + return true; + } catch (\Throwable $e) { + $db->transactionRollback(); + throw $e; + } + } + + public static function getActiveRidesForDriver(int $driverId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitetaxi_rides') + ->where('driver_id = ' . (int) $driverId) + ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['dispatched', 'accepted', 'arriving', 'in_progress'])) . ')') + ->order('created DESC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/FareHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/FareHelper.php new file mode 100644 index 0000000..87913b7 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/FareHelper.php @@ -0,0 +1,126 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class FareHelper +{ + public static function calculateFare( + float $distanceKm, + float $durationMinutes, + string $vehicleType = 'sedan', + ?int $zoneId = null, + float $surgeMultiplier = 1.0 + ): object { + $rule = self::getApplicableRule($vehicleType, $zoneId); + + $baseFare = (float) $rule->base_fare; + $distanceCharge = $distanceKm * (float) $rule->per_km; + $timeCharge = $durationMinutes * (float) $rule->per_minute; + + $subtotal = ($baseFare + $distanceCharge + $timeCharge) * $surgeMultiplier; + $subtotal += (float) $rule->booking_fee; + + $hour = (int) date('H'); + if ($hour >= 22 || $hour < 6) { + $subtotal += (float) $rule->night_surcharge; + } + + $subtotal *= (float) ($rule->peak_multiplier ?? 1.0); + $total = max($subtotal, (float) $rule->minimum_fare); + + return (object) [ + 'fare_id' => (int) $rule->id, + 'base_fare' => $baseFare, + 'distance_charge' => round($distanceCharge, 2), + 'time_charge' => round($timeCharge, 2), + 'surge_multiplier' => $surgeMultiplier, + 'surcharges' => round((float) $rule->night_surcharge + (float) $rule->booking_fee, 2), + 'total_fare' => round($total, 2), + ]; + } + + public static function estimateFare( + float $pickupLat, + float $pickupLng, + float $dropoffLat, + float $dropoffLng, + string $vehicleType = 'sedan' + ): object { + $distanceKm = TaxiHelper::haversineDistance($pickupLat, $pickupLng, $dropoffLat, $dropoffLng); + $distanceKm *= 1.3; // road distance approximation + $durationMinutes = $distanceKm * 2.0; + + $estimate = self::calculateFare($distanceKm, $durationMinutes, $vehicleType); + $estimate->estimated_distance_km = round($distanceKm, 2); + $estimate->estimated_duration_minutes = round($durationMinutes, 1); + + return $estimate; + } + + public static function getApplicableRule(string $vehicleType = 'sedan', ?int $zoneId = null): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->format('Y-m-d'); + + $query = $db->getQuery(true) + ->select('*') + ->from('#__mokosuitetaxi_fares') + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('effective_from') . ' IS NULL OR ' . $db->quoteName('effective_from') . ' <= ' . $db->quote($now) . ')') + ->where('(' . $db->quoteName('effective_to') . ' IS NULL OR ' . $db->quoteName('effective_to') . ' >= ' . $db->quote($now) . ')') + ->order('zone_id DESC, vehicle_type DESC') + ->setLimit(1); + + if ($zoneId) { + $query->where('(' . $db->quoteName('zone_id') . ' = ' . (int) $zoneId . ' OR ' . $db->quoteName('zone_id') . ' IS NULL)'); + } else { + $query->where($db->quoteName('zone_id') . ' IS NULL'); + } + + $query->where('(' . $db->quoteName('vehicle_type') . ' = ' . $db->quote($vehicleType) . ' OR ' . $db->quoteName('vehicle_type') . ' = ' . $db->quote('all') . ')'); + + $db->setQuery($query); + $rule = $db->loadObject(); + + if (!$rule) { + return (object) [ + 'id' => 0, 'base_fare' => 2.50, 'per_km' => 1.50, 'per_minute' => 0.25, + 'minimum_fare' => 5.00, 'booking_fee' => 0.00, 'peak_multiplier' => 1.00, + 'night_surcharge' => 0.00, 'airport_surcharge' => 0.00, + ]; + } + + return $rule; + } + + public static function calculateDriverPayout(float $totalFare, ?float $driverCommissionRate = null): object + { + if ($driverCommissionRate === null) { + $params = Factory::getApplication()->bootPlugin('mokosuitetaxi', 'system')->params; + $driverCommissionRate = (float) ($params->get('commission_rate', 20)); + } + + $platformFee = round($totalFare * ($driverCommissionRate / 100), 2); + $driverPayout = round($totalFare - $platformFee, 2); + + return (object) [ + 'total_fare' => $totalFare, + 'commission_rate' => $driverCommissionRate, + 'platform_fee' => $platformFee, + 'driver_payout' => $driverPayout, + ]; + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/RideHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/RideHelper.php new file mode 100644 index 0000000..d68fba0 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/RideHelper.php @@ -0,0 +1,211 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class RideHelper +{ + private const STATUS_FLOW = [ + 'requested' => ['dispatched', 'cancelled', 'no_driver'], + 'dispatched' => ['accepted', 'cancelled', 'no_driver'], + 'accepted' => ['arriving', 'cancelled'], + 'arriving' => ['in_progress', 'cancelled'], + 'in_progress' => ['completed', 'cancelled'], + 'completed' => [], + 'cancelled' => [], + 'no_driver' => ['requested'], + ]; + + public static function requestRide(array $data): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $user = Factory::getApplication()->getIdentity(); + + $ride = (object) [ + 'ride_ref' => TaxiHelper::generateRideRef(), + 'rider_contact_id' => isset($data['rider_contact_id']) ? (int) $data['rider_contact_id'] : null, + 'rider_name' => $data['rider_name'], + 'rider_phone' => $data['rider_phone'] ?? '', + 'status' => 'requested', + 'ride_type' => $data['ride_type'] ?? 'on_demand', + 'vehicle_type_requested' => $data['vehicle_type'] ?? 'any', + 'pickup_address' => $data['pickup_address'], + 'pickup_lat' => $data['pickup_lat'] ?? null, + 'pickup_lng' => $data['pickup_lng'] ?? null, + 'dropoff_address' => $data['dropoff_address'] ?? '', + 'dropoff_lat' => $data['dropoff_lat'] ?? null, + 'dropoff_lng' => $data['dropoff_lng'] ?? null, + 'scheduled_at' => $data['scheduled_at'] ?? null, + 'passenger_count' => (int) ($data['passenger_count'] ?? 1), + 'payment_method' => $data['payment_method'] ?? 'cash', + 'notes' => $data['notes'] ?? '', + 'created' => $now, + 'created_by' => $user ? (int) $user->id : 0, + ]; + + if ($ride->pickup_lat && $ride->pickup_lng && $ride->dropoff_lat && $ride->dropoff_lng) { + $estimate = FareHelper::estimateFare( + (float) $ride->pickup_lat, (float) $ride->pickup_lng, + (float) $ride->dropoff_lat, (float) $ride->dropoff_lng, + $ride->vehicle_type_requested === 'any' ? 'sedan' : $ride->vehicle_type_requested + ); + $ride->fare_id = $estimate->fare_id; + $ride->base_fare = $estimate->base_fare; + $ride->distance_charge = $estimate->distance_charge; + $ride->time_charge = $estimate->time_charge; + $ride->surcharges = $estimate->surcharges; + $ride->total_fare = $estimate->total_fare; + } + + $db->insertObject('#__mokosuitetaxi_rides', $ride, 'id'); + + return $ride; + } + + public static function updateStatus(int $rideId, string $newStatus): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->setQuery($db->getQuery(true) + ->select('id, status, driver_id') + ->from('#__mokosuitetaxi_rides') + ->where('id = ' . (int) $rideId)); + $ride = $db->loadObject(); + + if (!$ride) { + return false; + } + + $allowed = self::STATUS_FLOW[$ride->status] ?? []; + + if (!in_array($newStatus, $allowed, true)) { + throw new \InvalidArgumentException( + "Cannot transition from '{$ride->status}' to '{$newStatus}'" + ); + } + + $update = (object) ['id' => $rideId, 'status' => $newStatus]; + + $timestampMap = [ + 'arriving' => 'arrived_at', + 'in_progress' => 'started_at', + 'completed' => 'completed_at', + 'cancelled' => 'cancelled_at', + ]; + + if (isset($timestampMap[$newStatus])) { + $field = $timestampMap[$newStatus]; + $update->$field = $now; + } + + $db->updateObject('#__mokosuitetaxi_rides', $update, 'id'); + + if ($newStatus === 'completed' && $ride->driver_id) { + self::updateDriverStats((int) $ride->driver_id); + } + + return true; + } + + public static function completeRide(int $rideId, float $distanceKm, float $durationMinutes): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitetaxi_rides')->where('id = ' . (int) $rideId)); + $ride = $db->loadObject(); + + if (!$ride || $ride->status !== 'in_progress') { + throw new \RuntimeException('Ride is not in progress'); + } + + $vehicleType = $ride->vehicle_type_requested === 'any' ? 'sedan' : $ride->vehicle_type_requested; + $fare = FareHelper::calculateFare($distanceKm, $durationMinutes, $vehicleType, null, (float) $ride->surge_multiplier); + $payout = FareHelper::calculateDriverPayout($fare->total_fare); + + $update = (object) [ + 'id' => $rideId, + 'status' => 'completed', + 'completed_at' => $now, + 'distance_km' => $distanceKm, + 'duration_minutes' => $durationMinutes, + 'base_fare' => $fare->base_fare, + 'distance_charge' => $fare->distance_charge, + 'time_charge' => $fare->time_charge, + 'surcharges' => $fare->surcharges, + 'total_fare' => $fare->total_fare, + 'driver_payout' => $payout->driver_payout, + 'platform_fee' => $payout->platform_fee, + ]; + + $db->updateObject('#__mokosuitetaxi_rides', $update, 'id'); + + if ($ride->driver_id) { + self::updateDriverStats((int) $ride->driver_id); + } + + return $update; + } + + public static function getById(int $rideId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + 'r.*', + 'd.name AS driver_name', 'd.phone AS driver_phone', 'd.rating AS driver_rating', + 'v.plate_number', 'v.make AS vehicle_make', 'v.model AS vehicle_model', 'v.color AS vehicle_color', + ]) + ->from($db->quoteName('#__mokosuitetaxi_rides', 'r')) + ->join('LEFT', $db->quoteName('#__mokosuitetaxi_drivers', 'd') . ' ON d.id = r.driver_id') + ->join('LEFT', $db->quoteName('#__mokosuitetaxi_vehicles', 'v') . ' ON v.id = r.vehicle_id') + ->where('r.id = ' . (int) $rideId); + + $db->setQuery($query); + + return $db->loadObject() ?: null; + } + + private static function updateDriverStats(int $driverId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select(['COUNT(*) AS total_rides', 'COALESCE(SUM(driver_payout), 0) AS total_earnings']) + ->from('#__mokosuitetaxi_rides') + ->where('driver_id = ' . (int) $driverId) + ->where($db->quoteName('status') . ' = ' . $db->quote('completed'))); + $stats = $db->loadObject(); + + $db->setQuery($db->getQuery(true) + ->select('COALESCE(AVG(rating), 5.00)') + ->from('#__mokosuitetaxi_ratings') + ->join('INNER', '#__mokosuitetaxi_rides AS r ON r.id = ride_id') + ->where('r.driver_id = ' . (int) $driverId) + ->where($db->quoteName('rated_by') . ' = ' . $db->quote('rider'))); + $avgRating = (float) $db->loadResult(); + + $db->updateObject('#__mokosuitetaxi_drivers', (object) [ + 'id' => $driverId, + 'total_rides' => (int) $stats->total_rides, + 'total_earnings' => (float) $stats->total_earnings, + 'rating' => round($avgRating, 2), + 'modified' => Factory::getDate()->toSql(), + ], 'id'); + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/TaxiHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/TaxiHelper.php new file mode 100644 index 0000000..22a9b56 --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/TaxiHelper.php @@ -0,0 +1,104 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class TaxiHelper +{ + public static function getDashboard(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_drivers') + ->where($db->quoteName('status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('published') . ' = 1')); + $activeDrivers = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_rides') + ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['requested', 'dispatched', 'accepted', 'arriving', 'in_progress'])) . ')')); + $activeRides = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_vehicles') + ->where($db->quoteName('status') . ' = ' . $db->quote('available')) + ->where($db->quoteName('published') . ' = 1')); + $availableVehicles = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('total_fare') . '), 0)') + ->from('#__mokosuitetaxi_rides') + ->where($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where($db->quoteName('payment_status') . ' = ' . $db->quote('paid')) + ->where('DATE(' . $db->quoteName('completed_at') . ') = CURDATE()')); + $todayRevenue = (float) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_rides') + ->where($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where('DATE(' . $db->quoteName('completed_at') . ') = CURDATE()')); + $todayCompleted = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COALESCE(AVG(' . $db->quoteName('r.rating') . '), 0)') + ->from($db->quoteName('#__mokosuitetaxi_ratings', 'r')) + ->where($db->quoteName('r.rated_by') . ' = ' . $db->quote('rider')) + ->where('DATE(' . $db->quoteName('r.created') . ') >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)')); + $avgRating = round((float) $db->loadResult(), 2); + + return (object) [ + 'active_drivers' => $activeDrivers, + 'active_rides' => $activeRides, + 'available_vehicles' => $availableVehicles, + 'today_revenue' => $todayRevenue, + 'today_completed' => $todayCompleted, + 'avg_rating_30d' => $avgRating, + ]; + } + + public static function generateRideRef(): string + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $prefix = 'TX'; + + do { + $ref = $prefix . strtoupper(bin2hex(random_bytes(4))); + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_rides') + ->where($db->quoteName('ride_ref') . ' = ' . $db->quote($ref))); + } while ((int) $db->loadResult() > 0); + + return $ref; + } + + public static function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float + { + $earthRadius = 6371.0; + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + $a = sin($dLat / 2) * sin($dLat / 2) + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * + sin($dLng / 2) * sin($dLng / 2); + + return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a)); + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/VehicleHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/VehicleHelper.php new file mode 100644 index 0000000..9566dee --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/VehicleHelper.php @@ -0,0 +1,97 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class VehicleHelper +{ + public static function getById(int $vehicleId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + 'v.*', + 'd.name AS assigned_driver', + 'd.id AS assigned_driver_id', + ]) + ->from($db->quoteName('#__mokosuitetaxi_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitetaxi_drivers', 'd') + . ' ON d.vehicle_id = v.id AND d.status = ' . $db->quote('active')) + ->where('v.id = ' . (int) $vehicleId); + + $db->setQuery($query); + + return $db->loadObject() ?: null; + } + + public static function getFleetSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select([ + $db->quoteName('status'), + 'COUNT(*) AS count', + ]) + ->from('#__mokosuitetaxi_vehicles') + ->where($db->quoteName('published') . ' = 1') + ->group('status')); + + $rows = $db->loadObjectList('status') ?: []; + + $db->setQuery($db->getQuery(true) + ->select([ + $db->quoteName('vehicle_type'), + 'COUNT(*) AS count', + ]) + ->from('#__mokosuitetaxi_vehicles') + ->where($db->quoteName('published') . ' = 1') + ->group('vehicle_type')); + + $byType = $db->loadObjectList('vehicle_type') ?: []; + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_vehicles') + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('insurance_expiry') . ' <= DATE_ADD(CURDATE(), INTERVAL 30 DAY)' + . ' OR ' . $db->quoteName('inspection_expiry') . ' <= DATE_ADD(CURDATE(), INTERVAL 30 DAY))')); + $expiringCount = (int) $db->loadResult(); + + return (object) [ + 'by_status' => $rows, + 'by_type' => $byType, + 'expiring_soon' => $expiringCount, + 'total' => array_sum(array_map(fn($r) => (int) $r->count, (array) $rows)), + ]; + } + + public static function getExpiringDocuments(int $days = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $deadline = Factory::getDate('+' . $days . ' days')->format('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('id, plate_number, make, model, insurance_expiry, inspection_expiry') + ->from('#__mokosuitetaxi_vehicles') + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('insurance_expiry') . ' <= ' . $db->quote($deadline) + . ' OR ' . $db->quoteName('inspection_expiry') . ' <= ' . $db->quote($deadline) . ')') + ->order('LEAST(COALESCE(insurance_expiry, ' . $db->quote('9999-12-31') . '), COALESCE(inspection_expiry, ' . $db->quote('9999-12-31') . ')) ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitetaxi/src/Helper/ZoneHelper.php b/source/packages/plg_system_mokosuitetaxi/src/Helper/ZoneHelper.php new file mode 100644 index 0000000..50a46aa --- /dev/null +++ b/source/packages/plg_system_mokosuitetaxi/src/Helper/ZoneHelper.php @@ -0,0 +1,104 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +declare(strict_types=1); + +namespace Moko\Plugin\System\MokoSuiteTaxi\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseInterface; + +class ZoneHelper +{ + public static function detectZone(float $lat, float $lng): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitetaxi_zones') + ->where($db->quoteName('published') . ' = 1') + ->where('center_lat IS NOT NULL') + ->where('center_lng IS NOT NULL') + ->order('ordering ASC')); + + $zones = $db->loadObjectList() ?: []; + + foreach ($zones as $zone) { + $radius = (float) ($zone->radius_km ?: 50.0); + $distance = TaxiHelper::haversineDistance($lat, $lng, (float) $zone->center_lat, (float) $zone->center_lng); + + if ($distance <= $radius) { + $zone->distance_from_center = round($distance, 2); + return $zone; + } + } + + return null; + } + + public static function getSurgeMultiplier(int $zoneId): float + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitetaxi_rides') + ->where($db->quoteName('pickup_zone_id') . ' = ' . (int) $zoneId) + ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['requested', 'dispatched'])) . ')') + ->where($db->quoteName('created') . ' >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)')); + $demandCount = (int) $db->loadResult(); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(DISTINCT d.id)') + ->from($db->quoteName('#__mokosuitetaxi_drivers', 'd')) + ->join('INNER', $db->quoteName('#__mokosuitetaxi_shifts', 's') . ' ON s.driver_id = d.id AND s.status = ' . $db->quote('active')) + ->where($db->quoteName('d.status') . ' = ' . $db->quote('active'))); + $supplyCount = max((int) $db->loadResult(), 1); + + $ratio = $demandCount / $supplyCount; + + if ($ratio <= 0.5) { + return 1.0; + } + + if ($ratio <= 1.0) { + return 1.25; + } + + if ($ratio <= 2.0) { + return 1.5; + } + + $params = Factory::getApplication()->bootPlugin('mokosuitetaxi', 'system')->params; + $maxSurge = (float) $params->get('max_surge_multiplier', 3.0); + + return min(1.5 + ($ratio - 2.0) * 0.5, $maxSurge); + } + + public static function listZones(string $type = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('*') + ->from('#__mokosuitetaxi_zones') + ->where($db->quoteName('published') . ' = 1') + ->order('ordering ASC'); + + if ($type) { + $query->where($db->quoteName('zone_type') . ' = ' . $db->quote($type)); + } + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_webservices_mokosuitetaxi/mokosuitetaxi.xml b/source/packages/plg_webservices_mokosuitetaxi/mokosuitetaxi.xml new file mode 100644 index 0000000..d8b2406 --- /dev/null +++ b/source/packages/plg_webservices_mokosuitetaxi/mokosuitetaxi.xml @@ -0,0 +1,10 @@ + + + Web Services - MokoSuite Taxi + mokosuitetaxi + Moko Consulting + 06.00.00 + GPL-3.0-or-later + Moko\Plugin\WebServices\MokoSuiteTaxi + srcservices + diff --git a/source/packages/plg_webservices_mokosuitetaxi/services/provider.php b/source/packages/plg_webservices_mokosuitetaxi/services/provider.php new file mode 100644 index 0000000..39d09c9 --- /dev/null +++ b/source/packages/plg_webservices_mokosuitetaxi/services/provider.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Moko\Plugin\WebServices\MokoSuiteTaxi\Extension\MokoSuiteTaxi; + +return new class implements ServiceProviderInterface { + public function register(Container $container): void { + $container->set(PluginInterface::class, function (Container $container) { + return new MokoSuiteTaxi($container->get(DispatcherInterface::class), (array) PluginHelper::getPlugin('webservices', 'mokosuitetaxi')); + }); + } +}; diff --git a/source/packages/plg_webservices_mokosuitetaxi/src/Extension/MokoSuiteTaxi.php b/source/packages/plg_webservices_mokosuitetaxi/src/Extension/MokoSuiteTaxi.php new file mode 100644 index 0000000..47cc288 --- /dev/null +++ b/source/packages/plg_webservices_mokosuitetaxi/src/Extension/MokoSuiteTaxi.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: GPL-3.0-or-later + * Authored-by: Moko Consulting + */ + +namespace Moko\Plugin\WebServices\MokoSuiteTaxi\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\SubscriberInterface; + +class MokoSuiteTaxi extends CMSPlugin implements SubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return ['onBeforeApiRoute' => 'onBeforeApiRoute']; + } + + public function onBeforeApiRoute(&$event): void + { + $router = $event->getArgument('router'); + $opts = ['component' => 'com_mokosuitetaxi']; + + $router->createCRUDRoutes('v1/mokosuitetaxi/rides', 'rides', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/vehicles', 'vehicles', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/drivers', 'drivers', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/zones', 'zones', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/fares', 'fares', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/dispatch', 'dispatch', $opts); + $router->createCRUDRoutes('v1/mokosuitetaxi/ratings', 'ratings', $opts); + } +} diff --git a/source/pkg_mokosuitetaxi.xml b/source/pkg_mokosuitetaxi.xml new file mode 100644 index 0000000..2c1dfb5 --- /dev/null +++ b/source/pkg_mokosuitetaxi.xml @@ -0,0 +1,22 @@ + + + Package - MokoSuite Taxi + mokosuitetaxi + 06.00.00 + 2026-06-27 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + Ride-hailing, dispatch, fleet management, fare zones, and driver scheduling + 8.3 + + true + + plg_system_mokosuitetaxi.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteTaxi/updates.xml + +