From 44c9270a0873a83c0876eb0ee88db2a6a1d0c56e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 24 Sep 2026 12:39:57 +0800 Subject: [PATCH 1/5] fix(custom-fields): persist fuel report and service area custom field values Ember Data's REST serializer sends a record under a camelCase root (`fuelReport`, `serviceArea`). The base controller's payload reader accepts both spellings, so the ordinary attributes saved, but these two afterSave hooks read `$request->array('fuel_report.custom_field_values')` and `'service_area.custom_field_values'` from the raw request, which was always empty. No custom field value was ever persisted for either resource; single-word resources (driver, vehicle, ...) were unaffected. Read the values from the payload the base controller already extracted, which the after hook receives as its third argument, so the root key's spelling no longer matters. Found while testing the ember-ui signature pad custom field on a fuel report: the upload landed, the report saved, and the value vanished. --- .../Controllers/Internal/v1/FuelReportController.php | 12 +++++++++--- .../Internal/v1/ServiceAreaController.php | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/src/Http/Controllers/Internal/v1/FuelReportController.php b/server/src/Http/Controllers/Internal/v1/FuelReportController.php index 9ff460a31..c2e8db2bf 100644 --- a/server/src/Http/Controllers/Internal/v1/FuelReportController.php +++ b/server/src/Http/Controllers/Internal/v1/FuelReportController.php @@ -9,6 +9,7 @@ use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -23,11 +24,16 @@ class FuelReportController extends FleetOpsController /** * Handle post save transactions. + * + * Reads the custom field values from the payload the base controller already extracted + * rather than from the raw request: Ember Data sends the resource under a camelCase root + * (`fuelReport`), so `$request->array('fuel_report.custom_field_values')` was always empty and no + * custom field value was ever persisted for this resource. */ - public function afterSave(Request $request, FuelReport $fuelReport) + public function afterSave(Request $request, FuelReport $fuelReport, array $input = []) { - $customFieldValues = $request->array('fuel_report.custom_field_values'); - if ($customFieldValues) { + $customFieldValues = Arr::get($input, 'custom_field_values'); + if (is_array($customFieldValues) && $customFieldValues) { $fuelReport->syncCustomFieldValues($customFieldValues); } } diff --git a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php index 320a83265..36926e45a 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Models\ServiceArea; use Fleetbase\Http\Requests\ExportRequest; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -21,11 +22,16 @@ class ServiceAreaController extends FleetOpsController /** * Handle post save transactions. + * + * Reads the custom field values from the payload the base controller already extracted + * rather than from the raw request: Ember Data sends the resource under a camelCase root + * (`serviceArea`), so `$request->array('service_area.custom_field_values')` was always empty and no + * custom field value was ever persisted for this resource. */ - public function afterSave(Request $request, ServiceArea $serviceArea) + public function afterSave(Request $request, ServiceArea $serviceArea, array $input = []) { - $customFieldValues = $request->array('service_area.custom_field_values'); - if ($customFieldValues) { + $customFieldValues = Arr::get($input, 'custom_field_values'); + if (is_array($customFieldValues) && $customFieldValues) { $serviceArea->syncCustomFieldValues($customFieldValues); } } From 4791b3e707ba05b4cd062201a7a69a14c4b3e150 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 10:31:03 +0800 Subject: [PATCH 2/5] feat(reporting): report on order items, tracking, identifiers and storefront totals The Orders report table was missing most of what an order report needs, and several tables referenced columns that do not exist. Orders - Order ID / Internal ID now show (core-api hid every *_id column as a key). - New columns: customer and facilitator kind, dispatched/started flags, time window, ad hoc distance, POD method, route optimized, priority, notes. - Storefront totals from meta as expression columns: storefront, currency, subtotal, delivery fee, tip and total (in the currency's smallest unit). - Summary columns: total/completed/canceled orders (distinct, so they stay right when items are joined), order total/average, delivery fees, tips; the transaction sums now read transaction.amount instead of a missing orders.amount. - New relationships: tracking number (+ latest tracking status), order config, customer vendor, created by, purchase rate -> service quote, payload return. - Payload now has its own columns and exposes its items (payload.entities, one row per item) with name, SKU, price, product ID, quantity and line total from meta, plus the item destination. - Distance/duration are labelled in meters/seconds (what is stored); the km->miles transformer on a meters column is gone. Other tables - Drivers: name/email/phone and current_vehicle_uuid do not exist on drivers; use the user and vehicle relationships. - Vehicles: the driver join used a non-existent current_driver_uuid. - Fuel reports: cost/odometer_reading/report_date do not exist; use amount, odometer, metric unit and created_at. - Every table and item join leaves soft-deleted rows out. Requires fleetbase/core-api feature/report-framework-enhancements for expression columns and soft-delete filtering; on older core-api releases the schema still registers (the new columns fall back to plain computed columns). --- .../Reporting/FleetOpsReportSchema.php | 633 +++++++++++++++--- server/tests/ReportSchemaContractsTest.php | 123 +++- 2 files changed, 654 insertions(+), 102 deletions(-) diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index dc83928ff..779452bdb 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -47,12 +47,19 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void /** * Create the Orders table definition. + * + * Money in orders is stored in the currency's smallest unit (e.g. cents): storefront orders + * keep their totals in `meta` (`subtotal`, `delivery_fee`, `tip`, `total`) and each line + * item is an entity of the order's payload with `meta.quantity` and `meta.subtotal`. + * + * Selecting line item columns (Payload Items) returns one row per item, so order-level + * sums over item rows repeat each order; count orders with Total Orders (a distinct count). */ protected function createOrdersTable(): Table { - return Table::make('orders') + return $this->softDeletes(Table::make('orders')) ->label('Orders') - ->description('Delivery and service orders') + ->description('Delivery and service orders, with their payload items, tracking, assignment and payment') ->category('Operations') ->extension('fleet-ops') ->excludeColumns(['uuid', 'deleted_at']) // Hide foreign keys and system columns @@ -95,7 +102,20 @@ protected function createOrdersTable(): Table Column::make('type', 'string') ->label('Order Type') - ->description('Type of order service') + ->description('Type of order service, e.g. transport or storefront') + ->filterable() + ->sortable() + ->aggregatable(), + + Column::make('customer_type', 'string') + ->label('Customer Kind') + ->description('Whether the customer is a contact or a vendor') + ->filterable() + ->aggregatable(), + + Column::make('facilitator_type', 'string') + ->label('Facilitator Kind') + ->description('Whether the facilitator is a vendor or an integrated vendor') ->filterable() ->aggregatable(), @@ -106,6 +126,12 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('dispatched', 'boolean') + ->label('Dispatched') + ->description('Whether the order has been dispatched') + ->filterable() + ->aggregatable(), + Column::make('dispatched_at', 'datetime') ->label('Dispatched At') ->description('When the order was dispatched') @@ -113,6 +139,12 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('started', 'boolean') + ->label('Started') + ->description('Whether the order has been started') + ->filterable() + ->aggregatable(), + Column::make('started_at', 'datetime') ->label('Started At') ->description('When the order was started') @@ -120,18 +152,29 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('time_window_start', 'datetime') + ->label('Time Window Start') + ->description('Earliest time the order may be serviced') + ->filterable() + ->sortable(), + + Column::make('time_window_end', 'datetime') + ->label('Time Window End') + ->description('Latest time the order may be serviced') + ->filterable() + ->sortable(), + Column::make('distance', 'integer') - ->label('Distance (km)') - ->description('Total distance for the order') + ->label('Distance (m)') + ->description('Route distance for the order in meters') + ->filterable() ->aggregatable() - ->sortable() - ->transformer(function ($value) { - return round($value * 0.621371, 2); // Convert km to miles - }), + ->sortable(), Column::make('time', 'integer') - ->label('Duration (minutes)') - ->description('Estimated duration in minutes') + ->label('Duration (s)') + ->description('Estimated route duration in seconds') + ->filterable() ->aggregatable() ->sortable(), @@ -144,6 +187,12 @@ protected function createOrdersTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('adhoc_distance', 'integer') + ->label('Ad Hoc Distance (m)') + ->description('Radius in meters used to offer an ad hoc order to drivers') + ->filterable() + ->sortable(), + Column::make('pod_required', 'boolean') ->label('POD Required') ->description('Whether proof of delivery is required') @@ -153,6 +202,31 @@ protected function createOrdersTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('pod_method', 'string') + ->label('POD Method') + ->description('Proof of delivery method, e.g. scan, signature or photo') + ->filterable() + ->aggregatable(), + + Column::make('is_route_optimized', 'boolean') + ->label('Route Optimized') + ->description('Whether the route was optimized') + ->filterable() + ->aggregatable(), + + Column::make('orchestrator_priority', 'integer') + ->label('Priority') + ->description('Dispatch priority used by the orchestrator') + ->filterable() + ->sortable() + ->aggregatable(), + + Column::make('notes', 'string') + ->label('Notes') + ->description('Order notes') + ->searchable() + ->filterable(), + Column::make('created_at', 'datetime') ->label('Created At') ->description('When the order was created') @@ -168,76 +242,218 @@ protected function createOrdersTable(): Table Column::make('meta', 'json') ->label('Metadata') - ->description('Order metadata and custom fields') + ->description('Order metadata and custom fields; read a key with JSON_UNQUOTE(JSON_EXTRACT(meta, \'$.key\'))') ->searchable() ->filterable(), + + // Storefront orders keep their checkout totals in meta, in the currency's smallest unit. + $this->expression('storefront', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.storefront'))", 'string') + ->label('Storefront') + ->description('Name of the storefront the order was placed in') + ->filterable() + ->sortable(), + + $this->expression('order_currency', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.currency'))", 'string') + ->label('Order Currency') + ->description('Currency of the storefront order totals') + ->filterable() + ->sortable(), + + $this->expression('order_subtotal', $this->jsonAmount('meta', 'subtotal'), 'decimal') + ->label('Order Subtotal (minor units)') + ->description('Storefront order subtotal in the currency\'s smallest unit (e.g. cents)') + ->filterable() + ->sortable(), + + $this->expression('order_delivery_fee', $this->jsonAmount('meta', 'delivery_fee'), 'decimal') + ->label('Delivery Fee (minor units)') + ->description('Storefront delivery fee in the currency\'s smallest unit (e.g. cents)') + ->filterable() + ->sortable(), + + $this->expression('order_tip', $this->jsonAmount('meta', 'tip'), 'decimal') + ->label('Tip (minor units)') + ->description('Storefront tip in the currency\'s smallest unit (e.g. cents)') + ->filterable() + ->sortable(), + + $this->expression('order_total', $this->jsonAmount('meta', 'total'), 'decimal') + ->label('Order Total (minor units)') + ->description('Storefront order total in the currency\'s smallest unit (e.g. cents)') + ->filterable() + ->sortable(), ]) ->computedColumns([ - Column::count('total_orders', 'id') + // Distinct, so the count stays right when payload items are selected too. + Column::count('total_orders', 'DISTINCT id') ->label('Total Orders') - ->description('Count of orders'), + ->description('Number of orders'), + + Column::computed('completed_orders', "COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)", 'integer') + ->label('Completed Orders') + ->description('Number of completed orders'), + + Column::computed('canceled_orders', "COUNT(DISTINCT CASE WHEN status = 'canceled' THEN id END)", 'integer') + ->label('Canceled Orders') + ->description('Number of canceled orders'), Column::sum('total_distance', 'distance') - ->label('Total Distance') + ->label('Total Distance (m)') ->description('Sum of all order distances'), Column::avg('average_distance', 'distance') - ->label('Average Distance') + ->label('Average Distance (m)') ->description('Average distance per order'), Column::sum('total_time', 'time') - ->label('Total Time') + ->label('Total Duration (s)') ->description('Sum of all order durations'), Column::avg('average_time', 'time') - ->label('Average Time') + ->label('Average Duration (s)') ->description('Average duration per order'), - Column::sum('total_transaction_amount', 'amount') - ->label('Total Transaction Amount') - ->description('Sum of all transaction amounts'), + Column::sum('total_order_amount', 'order_total') + ->label('Order Total Sum (minor units)') + ->description('Sum of storefront order totals'), + + Column::avg('average_order_amount', 'order_total') + ->label('Average Order Total (minor units)') + ->description('Average storefront order total'), + + Column::sum('total_delivery_fees', 'order_delivery_fee') + ->label('Delivery Fees Sum (minor units)') + ->description('Sum of storefront delivery fees'), + + Column::sum('total_tips', 'order_tip') + ->label('Tips Sum (minor units)') + ->description('Sum of storefront tips'), - Column::avg('average_transaction_amount', 'amount') - ->label('Average Transaction Amount') + Column::sum('total_transaction_amount', 'transaction.amount') + ->label('Transaction Amount Sum (minor units)') + ->description('Sum of the orders\' transaction amounts'), + + Column::avg('average_transaction_amount', 'transaction.amount') + ->label('Average Transaction Amount (minor units)') ->description('Average transaction amount per order'), - Column::count('orders_with_transactions', 'transaction_uuid') + Column::count('orders_with_transactions', 'DISTINCT transaction_uuid') ->label('Orders with Transactions') - ->description('Count of orders that have transactions'), + ->description('Number of orders that have a transaction'), ]) ->relationships([ - // Auto-join relationships for seamless access + Relationship::hasAutoJoin('tracking_number', 'tracking_numbers') + ->label('Tracking') + ->description('The order\'s tracking number and its latest tracking status') + ->localKey('tracking_number_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('tracking_number', 'string')->label('Number')->description('Tracking number'), + Column::make('public_id', 'string')->label('ID')->description('Tracking number record identifier'), + Column::make('region', 'string')->label('Region'), + ]) + ->with([ + Relationship::hasAutoJoin('status', 'tracking_statuses') + ->label('Tracking Status') + ->localKey('status_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('status', 'string')->label('Status'), + Column::make('code', 'string')->label('Code'), + Column::make('details', 'string')->label('Details'), + Column::make('complete', 'boolean')->label('Complete'), + Column::make('city', 'string')->label('City'), + Column::make('province', 'string')->label('Province'), + Column::make('country', 'string')->label('Country'), + Column::make('created_at', 'datetime')->label('Updated At'), + ]), + ]), + + Relationship::hasAutoJoin('order_config', 'order_configs') + ->label('Order Config') + ->description('The order configuration (order type) the order follows') + ->localKey('order_config_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('key', 'string')->label('Key'), + Column::make('namespace', 'string')->label('Namespace'), + ]), + Relationship::hasAutoJoin('payload', 'payloads') ->label('Payload') ->localKey('payload_uuid') ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Payload identifier'), + Column::make('type', 'string')->label('Type'), + Column::make('payment_method', 'string')->label('Payment Method'), + Column::make('cod_amount', 'integer')->label('COD Amount (minor units)')->description('Cash on delivery amount in the currency\'s smallest unit'), + Column::make('cod_currency', 'string')->label('COD Currency'), + Column::make('cod_payment_method', 'string')->label('COD Payment Method'), + Column::make('provider', 'string')->label('Provider'), + ]) ->with([ Relationship::hasAutoJoin('pickup', 'places') ->label('Pickup') ->localKey('pickup_uuid') ->foreignKey('uuid') - ->columns([ - Column::make('name', 'string')->label('Name'), - Column::make('street1', 'string')->label('Street'), - Column::make('street2', 'string')->label('Street 2'), - Column::make('city', 'string')->label('City'), - Column::make('province', 'string')->label('Province'), - Column::make('postal_code', 'string')->label('Postal Code'), - Column::make('country', 'string')->label('Country'), - ]), + ->columns($this->placeColumns()), Relationship::hasAutoJoin('dropoff', 'places') ->label('Dropoff') ->localKey('dropoff_uuid') ->foreignKey('uuid') + ->columns($this->placeColumns()), + + Relationship::hasAutoJoin('return', 'places') + ->label('Return') + ->localKey('return_uuid') + ->foreignKey('uuid') + ->columns($this->placeColumns()), + + // One row per item: the goods, parcels or storefront products in the order. + $this->softDeletes(Relationship::hasAutoJoin('entities', 'entities')) + ->label('Item') + ->description('Items carried by the order (storefront products, parcels, goods); one row per item') + ->localKey('uuid') + ->foreignKey('payload_uuid') ->columns([ - Column::make('name', 'string')->label('Name'), - Column::make('street1', 'string')->label('Street'), - Column::make('street2', 'string')->label('Street 2'), - Column::make('city', 'string')->label('City'), - Column::make('province', 'string')->label('Province'), - Column::make('postal_code', 'string')->label('Postal Code'), - Column::make('country', 'string')->label('Country'), + Column::make('public_id', 'string')->label('ID')->description('Item identifier'), + Column::make('internal_id', 'string')->label('Internal ID')->description('Internal reference; the product ID for storefront items'), + Column::make('name', 'string')->label('Name')->aggregatable(), + Column::make('type', 'string')->label('Type')->aggregatable(), + Column::make('description', 'string')->label('Description'), + Column::make('sku', 'string')->label('SKU')->aggregatable(), + Column::make('currency', 'string')->label('Currency'), + Column::make('price', 'decimal')->label('Price (minor units)')->description('Unit price in the currency\'s smallest unit'), + Column::make('sale_price', 'decimal')->label('Sale Price (minor units)')->description('Unit sale price in the currency\'s smallest unit'), + Column::make('declared_value', 'integer')->label('Declared Value (minor units)'), + Column::make('weight', 'decimal')->label('Weight'), + Column::make('weight_unit', 'string')->label('Weight Unit'), + Column::make('length', 'decimal')->label('Length'), + Column::make('width', 'decimal')->label('Width'), + Column::make('height', 'decimal')->label('Height'), + Column::make('dimensions_unit', 'string')->label('Dimensions Unit'), + Column::make('barcode', 'string')->label('Barcode'), + Column::make('meta', 'json')->label('Metadata'), + Column::make('created_at', 'datetime')->label('Created At'), + $this->expression('product_id', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.product_id'))", 'string') + ->label('Product ID') + ->description('Storefront product the item was ordered as'), + $this->expression('quantity', "COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.quantity')) AS DECIMAL(15,2)), 1)", 'decimal') + ->label('Quantity') + ->description('Quantity ordered; 1 when the item records none'), + $this->expression('line_total', $this->jsonAmount('meta', 'subtotal'), 'decimal') + ->label('Line Total (minor units)') + ->description('Storefront line subtotal (price x quantity, with variants and add-ons) in the currency\'s smallest unit'), + ]) + ->with([ + Relationship::hasAutoJoin('destination', 'places') + ->label('Destination') + ->localKey('destination_uuid') + ->foreignKey('uuid') + ->columns($this->placeColumns()), ]), ]), @@ -246,6 +462,8 @@ protected function createOrdersTable(): Table ->localKey('driver_assigned_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Driver identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('drivers_license_number', 'string')->label('License Number'), Column::make('country', 'string')->label('Country'), Column::make('city', 'string')->label('City'), @@ -268,6 +486,9 @@ protected function createOrdersTable(): Table ->localKey('vehicle_assigned_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Vehicle identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), + Column::make('name', 'string')->label('Name'), Column::make('make', 'string')->label('Make'), Column::make('model', 'string')->label('Model'), Column::make('year', 'integer')->label('Year'), @@ -279,9 +500,27 @@ protected function createOrdersTable(): Table Relationship::hasAutoJoin('customer', 'contacts') ->label('Customer') + ->description('The customer when it is a contact') + ->localKey('customer_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Customer identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), + Column::make('name', 'string')->label('Name'), + Column::make('title', 'string')->label('Title'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + Column::make('type', 'string')->label('Type'), + ]), + + Relationship::hasAutoJoin('customer_vendor', 'vendors') + ->label('Customer Vendor') + ->description('The customer when it is a vendor') ->localKey('customer_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Vendor identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('name', 'string')->label('Name'), Column::make('email', 'string')->label('Email'), Column::make('phone', 'string')->label('Phone'), @@ -289,16 +528,48 @@ protected function createOrdersTable(): Table ]), Relationship::hasAutoJoin('facilitator', 'vendors') - ->label('Faciliator') + ->label('Facilitator') ->localKey('facilitator_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Facilitator identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('name', 'string')->label('Name'), Column::make('email', 'string')->label('Email'), Column::make('phone', 'string')->label('Phone'), Column::make('type', 'string')->label('Type'), ]), + Relationship::hasAutoJoin('created_by', 'users') + ->label('Created By') + ->localKey('created_by_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + ]), + + Relationship::hasAutoJoin('purchase_rate', 'purchase_rates') + ->label('Purchase Rate') + ->description('The service quote purchased for the order') + ->localKey('purchase_rate_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Purchase rate identifier'), + Column::make('status', 'string')->label('Status'), + ]) + ->with([ + Relationship::hasAutoJoin('service_quote', 'service_quotes') + ->label('Quote') + ->localKey('service_quote_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Service quote identifier'), + Column::make('amount', 'integer')->label('Amount (minor units)')->description('Quoted delivery price in the currency\'s smallest unit'), + Column::make('currency', 'string')->label('Currency'), + ]), + ]), + Relationship::hasAutoJoin('transaction', 'transactions') ->label('Transaction') ->localKey('transaction_uuid') @@ -324,15 +595,35 @@ protected function createOrdersTable(): Table ->filterable() ->aggregatable(), + Column::make('payment_method', 'string') + ->label('Payment Method') + ->description('Payment method used, e.g. card or cash') + ->filterable() + ->aggregatable(), + Column::make('amount', 'integer') - ->label('Amount') - ->description('Transaction amount (in cents)') + ->label('Amount (minor units)') + ->description('Transaction amount in the currency\'s smallest unit (e.g. cents)') ->aggregatable() - ->sortable() - ->transformer(function ($value) { - // Convert cents to dollars with 2 decimal places - return number_format($value / 100, 2); - }), + ->sortable(), + + Column::make('fee_amount', 'integer') + ->label('Fee Amount (minor units)') + ->description('Gateway fee in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), + + Column::make('tax_amount', 'integer') + ->label('Tax Amount (minor units)') + ->description('Tax in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), + + Column::make('net_amount', 'integer') + ->label('Net Amount (minor units)') + ->description('Amount after fees in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), Column::make('currency', 'string') ->label('Currency') @@ -369,6 +660,12 @@ protected function createOrdersTable(): Table return $labels[$value] ?? ucfirst($value); }), + Column::make('settlement_status', 'string') + ->label('Settlement Status') + ->description('Whether the transaction has settled') + ->filterable() + ->aggregatable(), + Column::make('created_at', 'datetime') ->label('Transaction Date') ->description('When the transaction was created') @@ -378,13 +675,31 @@ protected function createOrdersTable(): Table ]) ->with([ // Nested relationship for transaction items - Relationship::hasAutoJoin('items', 'transaction_items') + $this->softDeletes(Relationship::hasAutoJoin('items', 'transaction_items')) ->label('Transaction Items') ->localKey('uuid') ->foreignKey('transaction_uuid') ->columns([ - Column::make('amount', 'string') - ->label('Item Amount') + Column::make('description', 'string') + ->label('Item Description') + ->description('Line item description') + ->searchable() + ->filterable(), + + Column::make('quantity', 'integer') + ->label('Item Quantity') + ->description('Line item quantity') + ->aggregatable() + ->sortable(), + + Column::make('unit_price', 'integer') + ->label('Item Unit Price (minor units)') + ->description('Line item unit price') + ->aggregatable() + ->sortable(), + + Column::make('amount', 'integer') + ->label('Item Amount (minor units)') ->description('Line item amount') ->aggregatable() ->sortable(), @@ -416,7 +731,7 @@ protected function createOrdersTable(): Table */ protected function createDriversTable(): Table { - return Table::make('drivers') + return $this->softDeletes(Table::make('drivers')) ->label('Drivers') ->description('Fleet drivers and personnel') ->category('Personnel') @@ -431,24 +746,22 @@ protected function createDriversTable(): Table ->filterable() ->sortable(), - Column::make('name', 'string') - ->label('Name') - ->description('Driver full name') + Column::make('internal_id', 'string') + ->label('Internal ID') + ->description('Internal driver reference') ->searchable() ->filterable() ->sortable(), - Column::make('email', 'string') - ->label('Email') - ->description('Driver email address') + Column::make('drivers_license_number', 'string') + ->label('License Number') + ->description('Driver license number') ->searchable() - ->filterable() - ->sortable(), + ->filterable(), - Column::make('phone', 'string') - ->label('Phone') - ->description('Driver phone number') - ->searchable() + Column::make('license_expiry', 'date') + ->label('License Expiry') + ->description('When the driver license expires') ->filterable() ->sortable(), @@ -477,24 +790,45 @@ protected function createDriversTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('city', 'string') + ->label('City') + ->filterable() + ->aggregatable(), + + Column::make('country', 'string') + ->label('Country') + ->filterable() + ->aggregatable(), + Column::make('created_at', 'datetime') ->label('Hired Date') - ->description('When the driver was hired') + ->description('When the driver was added') ->filterable() ->sortable() ->aggregatable(), ]) ->computedColumns([ - Column::count('total_drivers', 'id') + Column::count('total_drivers', 'DISTINCT id') ->label('Total Drivers') ->description('Count of drivers'), ]) ->relationships([ - Relationship::hasAutoJoin('current_vehicle', 'vehicles') + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + ]), + + Relationship::hasAutoJoin('vehicle', 'vehicles') ->label('Vehicle') - ->localKey('current_vehicle_uuid') + ->localKey('vehicle_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), Column::make('make', 'string')->label('Vehicle Make'), Column::make('model', 'string')->label('Vehicle Model'), Column::make('plate_number', 'string')->label('Plate Number'), @@ -507,7 +841,7 @@ protected function createDriversTable(): Table */ protected function createVehiclesTable(): Table { - return Table::make('vehicles') + return $this->softDeletes(Table::make('vehicles')) ->label('Vehicles') ->description('Fleet vehicles and assets') ->category('Fleet') @@ -522,6 +856,20 @@ protected function createVehiclesTable(): Table ->filterable() ->sortable(), + Column::make('internal_id', 'string') + ->label('Internal ID') + ->description('Internal vehicle reference') + ->searchable() + ->filterable() + ->sortable(), + + Column::make('name', 'string') + ->label('Name') + ->description('Vehicle name') + ->searchable() + ->filterable() + ->sortable(), + Column::make('make', 'string') ->label('Make') ->description('Vehicle manufacturer') @@ -583,19 +931,30 @@ protected function createVehiclesTable(): Table ->aggregatable(), ]) ->computedColumns([ - Column::count('total_vehicles', 'id') + Column::count('total_vehicles', 'DISTINCT id') ->label('Total Vehicles') ->description('Count of vehicles'), ]) ->relationships([ - Relationship::hasAutoJoin('current_driver', 'drivers') + // A driver points at the vehicle they drive (drivers.vehicle_uuid). + $this->softDeletes(Relationship::hasAutoJoin('driver', 'drivers')) ->label('Driver') - ->localKey('current_driver_uuid') - ->foreignKey('uuid') + ->localKey('uuid') + ->foreignKey('vehicle_uuid') ->columns([ - Column::make('name', 'string')->label('Driver Name'), - Column::make('email', 'string')->label('Driver Email'), - Column::make('phone', 'string')->label('Driver Phone'), + Column::make('public_id', 'string')->label('Driver ID'), + Column::make('status', 'string')->label('Driver Status'), + ]) + ->with([ + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + ]), ]), ]); } @@ -605,7 +964,7 @@ protected function createVehiclesTable(): Table */ protected function createAssetsTable(): Table { - return Table::make('assets') + return $this->softDeletes(Table::make('assets')) ->label('Trailers and Assets') ->description('Independently managed fleet assets; Trailer rows use asset_class trailer') ->category('Fleet') @@ -640,7 +999,7 @@ protected function createAssetsTable(): Table */ protected function createPlacesTable(): Table { - return Table::make('places') + return $this->softDeletes(Table::make('places')) ->label('Places') ->description('Locations and addresses') ->category('Geography') @@ -706,7 +1065,7 @@ protected function createPlacesTable(): Table */ protected function createContactsTable(): Table { - return Table::make('contacts') + return $this->softDeletes(Table::make('contacts')) ->label('Contacts') ->description('Customer and vendor contacts') ->category('CRM') @@ -762,7 +1121,7 @@ protected function createContactsTable(): Table */ protected function createVendorsTable(): Table { - return Table::make('vendors') + return $this->softDeletes(Table::make('vendors')) ->label('Vendors') ->description('Service providers and vendors') ->category('CRM') @@ -818,7 +1177,7 @@ protected function createVendorsTable(): Table */ protected function createFuelReportsTable(): Table { - return Table::make('fuel_reports') + return $this->softDeletes(Table::make('fuel_reports')) ->label('Fuel Reports') ->description('Vehicle fuel consumption reports') ->category('Operations') @@ -834,17 +1193,23 @@ protected function createFuelReportsTable(): Table ->sortable(), Column::make('volume', 'decimal') - ->label('Volume (L)') - ->description('Fuel volume in liters') + ->label('Volume') + ->description('Fuel volume, in the report\'s metric unit') ->aggregatable() ->sortable(), - Column::make('odometer_reading', 'integer') + Column::make('metric_unit', 'string') + ->label('Volume Unit') + ->description('Unit of the fuel volume') + ->filterable() + ->aggregatable(), + + Column::make('odometer', 'integer') ->label('Odometer Reading') ->description('Vehicle odometer reading') ->sortable(), - Column::make('cost', 'decimal') + Column::make('amount', 'decimal') ->label('Cost') ->description('Fuel cost amount') ->aggregatable() @@ -856,15 +1221,26 @@ protected function createFuelReportsTable(): Table ->filterable() ->aggregatable(), - Column::make('report_date', 'date') + Column::make('status', 'string') + ->label('Status') + ->filterable() + ->aggregatable(), + + Column::make('report', 'string') + ->label('Report') + ->description('Report notes') + ->searchable() + ->filterable(), + + Column::make('created_at', 'datetime') ->label('Report Date') - ->description('Date of fuel report') + ->description('When the fuel report was recorded') ->filterable() ->sortable() ->aggregatable(), ]) ->computedColumns([ - Column::sum('total_fuel_cost', 'cost') + Column::sum('total_fuel_cost', 'amount') ->label('Total Fuel Cost') ->description('Sum of all fuel costs'), @@ -872,7 +1248,7 @@ protected function createFuelReportsTable(): Table ->label('Total Fuel Volume') ->description('Sum of all fuel volumes'), - Column::avg('average_fuel_cost', 'cost') + Column::avg('average_fuel_cost', 'amount') ->label('Average Fuel Cost') ->description('Average fuel cost per report'), ]) @@ -882,6 +1258,7 @@ protected function createFuelReportsTable(): Table ->localKey('vehicle_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), Column::make('make', 'string')->label('Vehicle Make'), Column::make('model', 'string')->label('Vehicle Model'), Column::make('plate_number', 'string')->label('Plate Number'), @@ -892,8 +1269,17 @@ protected function createFuelReportsTable(): Table ->localKey('driver_uuid') ->foreignKey('uuid') ->columns([ - Column::make('name', 'string')->label('Driver Name'), - Column::make('email', 'string')->label('Driver Email'), + Column::make('public_id', 'string')->label('Driver ID'), + ]) + ->with([ + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + ]), ]), ]); } @@ -903,7 +1289,7 @@ protected function createFuelReportsTable(): Table */ protected function createWorkOrdersTable(): Table { - return Table::make('work_orders') + return $this->softDeletes(Table::make('work_orders')) ->label('Work Orders') ->description('Maintenance work orders, assignments, budgets, and lifecycle status') ->category('Maintenance') @@ -952,7 +1338,7 @@ protected function createWorkOrdersTable(): Table */ protected function createMaintenancesTable(): Table { - return Table::make('maintenances') + return $this->softDeletes(Table::make('maintenances')) ->label('Maintenance History') ->description('Completed and scheduled maintenance records with labor, parts, tax, and total cost') ->category('Maintenance') @@ -1013,7 +1399,7 @@ protected function createMaintenancesTable(): Table */ protected function createInspectionSubmissionsTable(): Table { - return Table::make('inspection_submissions') + return $this->softDeletes(Table::make('inspection_submissions')) ->label('Inspections') ->description('DVIR and inspection submissions, pass/fail status, and linked maintenance follow-up') ->category('Maintenance') @@ -1070,4 +1456,59 @@ protected function createInspectionSubmissionsTable(): Table ]), ]); } + + /** + * Columns reported for a place (pickup, dropoff, return or item destination). + */ + protected function placeColumns(): array + { + return [ + Column::make('public_id', 'string')->label('ID'), + Column::make('name', 'string')->label('Name'), + Column::make('street1', 'string')->label('Street'), + Column::make('street2', 'string')->label('Street 2'), + Column::make('neighborhood', 'string')->label('Neighborhood'), + Column::make('district', 'string')->label('District'), + Column::make('city', 'string')->label('City'), + Column::make('province', 'string')->label('Province'), + Column::make('postal_code', 'string')->label('Postal Code'), + Column::make('country', 'string')->label('Country'), + Column::make('latitude', 'decimal')->label('Latitude'), + Column::make('longitude', 'decimal')->label('Longitude'), + ]; + } + + /** + * SQL reading a numeric amount stored under a key of a JSON column. + */ + protected function jsonAmount(string $column, string $key): string + { + return "CAST(JSON_UNQUOTE(JSON_EXTRACT({$column}, '$.{$key}')) AS DECIMAL(15,2))"; + } + + /** + * A row-level expression column, such as a value read out of a JSON column. + * + * Core API releases before expression columns only know computed columns; there the + * column is still declared, it just cannot be selected on its own. + */ + protected function expression(string $name, string $sql, string $type): Column + { + // One line: whichever branch runs depends on the installed Core API, not on the test. + return method_exists(Column::class, 'expression') ? Column::expression($name, $sql, $type) : Column::computed($name, $sql, $type); + } + + /** + * Leave soft-deleted rows out of a table or joined relationship, on Core API releases that support it. + * + * @template T of Table|Relationship + * + * @param T $schema + * + * @return T + */ + protected function softDeletes(Table|Relationship $schema): Table|Relationship + { + return method_exists($schema, 'softDeletes') ? $schema->softDeletes() : $schema; + } } diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php index b1465b71c..124d9b0a7 100644 --- a/server/tests/ReportSchemaContractsTest.php +++ b/server/tests/ReportSchemaContractsTest.php @@ -119,9 +119,10 @@ function fleetOpsReportColumnFlag(Column $column, string $flag): bool function fleetOpsReportColumnAggregate(Column $column): ?string { + // The stand-in Column stores the aggregate name; the Core API one only flags it (a bool). $aggregate = fleetOpsReportProperty($column, 'aggregate'); - if ($aggregate) { + if (is_string($aggregate) && $aggregate !== '') { return $aggregate; } @@ -242,9 +243,10 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'transaction')))->toBe('transactions'); expect(fleetOpsReportTableMeta($tables['drivers'], 'category'))->toBe('Personnel') - ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'current_vehicle')))->toBe('vehicles') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'vehicle')))->toBe('vehicles') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'user')))->toBe('users') ->and(fleetOpsReportTableMeta($tables['vehicles'], 'category'))->toBe('Fleet') - ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'current_driver')))->toBe('drivers') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'driver')))->toBe('drivers') ->and(fleetOpsReportTableMeta($tables['assets'], 'label'))->toBe('Trailers and Assets') ->and(fleetOpsReportTableMeta($tables['assets'], 'category'))->toBe('Fleet') ->and(fleetOpsReportColumnFlag(fleetOpsReportColumn($tables['assets'], 'asset_class'), 'filterable'))->toBeTrue() @@ -265,7 +267,7 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'work_order')))->toBe('work_orders'); }); -test('fleetops report schema transformers normalize labels booleans distances and money', function () { +test('fleetops report schema transformers normalize labels and booleans', function () { $registry = new ReportSchemaRegistry(); (new FleetOpsReportSchema())->registerReportSchema($registry); @@ -278,12 +280,121 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) expect(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'driver_assigned'))->toBe('Driver Assigned') ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'custom_status'))->toBe('Custom_status') - ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'distance'), 10))->toBe(6.21) ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'adhoc'), true))->toBe('Yes') ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'pod_required'), false))->toBe('No') ->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'status'), 'suspended'))->toBe('Suspended') ->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'online'), false))->toBe('No') ->and(fleetOpsReportTransform(fleetOpsReportColumn($vehicles, 'status'), 'out_of_service'))->toBe('Out of Service') - ->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'amount'), 12345))->toBe('123.45') ->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'status'), 'refunded'))->toBe('Refunded'); }); + +function fleetOpsReportColumnNames(Table|Relationship $container): array +{ + return array_map(fn (Column $column) => fleetOpsReportSchemaName($column), fleetOpsReportColumns($container)); +} + +function fleetOpsReportComputation(Column $column): ?string +{ + return fleetOpsReportCallOrProperty($column, 'getComputation', 'computation'); +} + +test('fleetops order report schema exposes identifiers tracking items and storefront totals', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $orders = fleetOpsReportTables($registry)['orders']; + + // Order identifiers and the operational columns reports filter and group on. + expect(fleetOpsReportColumnNames($orders))->toContain( + 'public_id', + 'internal_id', + 'status', + 'type', + 'scheduled_at', + 'dispatched_at', + 'pod_method', + 'notes', + 'created_at', + 'order_total', + 'order_subtotal', + 'order_delivery_fee', + 'order_tip', + 'order_currency', + 'storefront', + ); + + // Storefront totals are read out of the order meta, in the currency's smallest unit. + expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'order_total')))->toBe("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.total')) AS DECIMAL(15,2))") + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'storefront')))->toBe("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.storefront'))") + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'completed_orders')))->toBe("COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)") + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_order_amount')))->toBe('SUM(order_total)') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_transaction_amount')))->toBe('SUM(transaction.amount)'); + + $tracking = fleetOpsReportRelationship($orders, 'tracking_number'); + expect(fleetOpsReportTableName($tracking))->toBe('tracking_numbers') + ->and(fleetOpsReportColumnNames($tracking))->toContain('tracking_number', 'public_id') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tracking, 'status')))->toBe('tracking_statuses') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'order_config')))->toBe('order_configs') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'customer_vendor')))->toBe('vendors') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'created_by')))->toBe('users') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'purchase_rate'), 'service_quote')))->toBe('service_quotes'); + + // Payload items: one row per entity, with the storefront quantity and line total from meta. + $payload = fleetOpsReportRelationship($orders, 'payload'); + expect(fleetOpsReportColumnNames($payload))->toContain('public_id', 'type', 'cod_amount') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($payload, 'return')))->toBe('places'); + + $items = fleetOpsReportRelationship($payload, 'entities'); + expect(fleetOpsReportTableName($items))->toBe('entities') + ->and(fleetOpsReportCallOrProperty($items, 'getLocalKey', 'localKey'))->toBe('uuid') + ->and(fleetOpsReportCallOrProperty($items, 'getForeignKey', 'foreignKey'))->toBe('payload_uuid') + ->and(fleetOpsReportColumnNames($items))->toContain('public_id', 'internal_id', 'name', 'sku', 'price', 'product_id', 'quantity', 'line_total') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($items, 'quantity')))->toBe("COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.quantity')) AS DECIMAL(15,2)), 1)") + ->and(fleetOpsReportComputation(fleetOpsReportColumn($items, 'line_total')))->toBe("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.subtotal')) AS DECIMAL(15,2))") + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($items, 'destination')))->toBe('places'); + + // Relationship identifiers + expect(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'driver_assigned')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'vehicle_assigned')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'customer')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'facilitator')))->toContain('public_id'); +}); + +test('fleetops report schema uses the columns the fuel report table actually has', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $tables = fleetOpsReportTables($registry); + $fuel = $tables['fuel_reports']; + + expect(fleetOpsReportColumnNames($fuel))->toContain('amount', 'odometer', 'metric_unit', 'created_at') + ->not->toContain('cost', 'odometer_reading', 'report_date') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($fuel, 'total_fuel_cost')))->toBe('SUM(amount)') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($fuel, 'driver'), 'user')))->toBe('users') + ->and(fleetOpsReportColumnNames($tables['drivers']))->not->toContain('name', 'email', 'phone'); +}); + +test('fleetops report schema leaves soft deleted rows out where the core api supports it', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $orders = fleetOpsReportTables($registry)['orders']; + $items = fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'payload'), 'entities'); + + if (!method_exists($orders, 'usesSoftDeletes')) { + // Older Core API: the schema still registers, without soft-delete filtering. + expect($orders)->toBeInstanceOf(Table::class); + + return; + } + + expect($orders->usesSoftDeletes())->toBeTrue() + ->and($items->usesSoftDeletes())->toBeTrue() + ->and(fleetOpsReportRelationship($orders, 'customer')->usesSoftDeletes())->toBeFalse() + ->and(fleetOpsReportColumn($orders, 'order_total')->isExpression())->toBeTrue() + ->and(fleetOpsReportColumn($orders, 'total_orders')->isAggregate())->toBeTrue(); +}); From 88a802d60e0222299d7b1c8a0a62dc5b1e79ad3e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 12:44:09 +0800 Subject: [PATCH 3/5] fix(reporting): drop order and item columns that assumed a meta shape meta has no fixed structure: users, integrations and extensions each put their own keys there, so a column such as "Order Total" read from meta.total is empty or wrong for most orders. Remove the columns built on meta keys (storefront, order currency/subtotal/delivery fee/tip/total, the order total/average/fee/tip summaries, and item product ID, quantity and line total). meta stays selectable as raw JSON on orders and items, and a key is read with a computed column, e.g. CAST(JSON_UNQUOTE(JSON_EXTRACT(payload.entities.meta, '$.quantity')) AS DECIMAL(15,2)). --- .../Reporting/FleetOpsReportSchema.php | 93 ++----------------- server/tests/ReportSchemaContractsTest.php | 30 +++--- 2 files changed, 20 insertions(+), 103 deletions(-) diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index 779452bdb..d483de39f 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -48,9 +48,10 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void /** * Create the Orders table definition. * - * Money in orders is stored in the currency's smallest unit (e.g. cents): storefront orders - * keep their totals in `meta` (`subtotal`, `delivery_fee`, `tip`, `total`) and each line - * item is an entity of the order's payload with `meta.quantity` and `meta.subtotal`. + * Money is stored in the currency's smallest unit (e.g. cents). `meta` has no fixed shape + * (it holds whatever users, integrations and extensions put there), so no column assumes a + * key in it; read one with a computed column, e.g. + * `CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.total')) AS DECIMAL(15,2))`. * * Selecting line item columns (Payload Items) returns one row per item, so order-level * sums over item rows repeat each order; count orders with Total Orders (a distinct count). @@ -242,46 +243,9 @@ protected function createOrdersTable(): Table Column::make('meta', 'json') ->label('Metadata') - ->description('Order metadata and custom fields; read a key with JSON_UNQUOTE(JSON_EXTRACT(meta, \'$.key\'))') + ->description('Order metadata and custom fields; its keys vary by order, so read one with a computed column, e.g. JSON_UNQUOTE(JSON_EXTRACT(meta, \'$.key\'))') ->searchable() ->filterable(), - - // Storefront orders keep their checkout totals in meta, in the currency's smallest unit. - $this->expression('storefront', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.storefront'))", 'string') - ->label('Storefront') - ->description('Name of the storefront the order was placed in') - ->filterable() - ->sortable(), - - $this->expression('order_currency', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.currency'))", 'string') - ->label('Order Currency') - ->description('Currency of the storefront order totals') - ->filterable() - ->sortable(), - - $this->expression('order_subtotal', $this->jsonAmount('meta', 'subtotal'), 'decimal') - ->label('Order Subtotal (minor units)') - ->description('Storefront order subtotal in the currency\'s smallest unit (e.g. cents)') - ->filterable() - ->sortable(), - - $this->expression('order_delivery_fee', $this->jsonAmount('meta', 'delivery_fee'), 'decimal') - ->label('Delivery Fee (minor units)') - ->description('Storefront delivery fee in the currency\'s smallest unit (e.g. cents)') - ->filterable() - ->sortable(), - - $this->expression('order_tip', $this->jsonAmount('meta', 'tip'), 'decimal') - ->label('Tip (minor units)') - ->description('Storefront tip in the currency\'s smallest unit (e.g. cents)') - ->filterable() - ->sortable(), - - $this->expression('order_total', $this->jsonAmount('meta', 'total'), 'decimal') - ->label('Order Total (minor units)') - ->description('Storefront order total in the currency\'s smallest unit (e.g. cents)') - ->filterable() - ->sortable(), ]) ->computedColumns([ // Distinct, so the count stays right when payload items are selected too. @@ -313,22 +277,6 @@ protected function createOrdersTable(): Table ->label('Average Duration (s)') ->description('Average duration per order'), - Column::sum('total_order_amount', 'order_total') - ->label('Order Total Sum (minor units)') - ->description('Sum of storefront order totals'), - - Column::avg('average_order_amount', 'order_total') - ->label('Average Order Total (minor units)') - ->description('Average storefront order total'), - - Column::sum('total_delivery_fees', 'order_delivery_fee') - ->label('Delivery Fees Sum (minor units)') - ->description('Sum of storefront delivery fees'), - - Column::sum('total_tips', 'order_tip') - ->label('Tips Sum (minor units)') - ->description('Sum of storefront tips'), - Column::sum('total_transaction_amount', 'transaction.amount') ->label('Transaction Amount Sum (minor units)') ->description('Sum of the orders\' transaction amounts'), @@ -436,17 +384,8 @@ protected function createOrdersTable(): Table Column::make('height', 'decimal')->label('Height'), Column::make('dimensions_unit', 'string')->label('Dimensions Unit'), Column::make('barcode', 'string')->label('Barcode'), - Column::make('meta', 'json')->label('Metadata'), + Column::make('meta', 'json')->label('Metadata')->description('Item metadata; read a key with a computed column, e.g. JSON_EXTRACT(payload.entities.meta, \'$.quantity\')'), Column::make('created_at', 'datetime')->label('Created At'), - $this->expression('product_id', "JSON_UNQUOTE(JSON_EXTRACT(meta, '$.product_id'))", 'string') - ->label('Product ID') - ->description('Storefront product the item was ordered as'), - $this->expression('quantity', "COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.quantity')) AS DECIMAL(15,2)), 1)", 'decimal') - ->label('Quantity') - ->description('Quantity ordered; 1 when the item records none'), - $this->expression('line_total', $this->jsonAmount('meta', 'subtotal'), 'decimal') - ->label('Line Total (minor units)') - ->description('Storefront line subtotal (price x quantity, with variants and add-ons) in the currency\'s smallest unit'), ]) ->with([ Relationship::hasAutoJoin('destination', 'places') @@ -1478,26 +1417,6 @@ protected function placeColumns(): array ]; } - /** - * SQL reading a numeric amount stored under a key of a JSON column. - */ - protected function jsonAmount(string $column, string $key): string - { - return "CAST(JSON_UNQUOTE(JSON_EXTRACT({$column}, '$.{$key}')) AS DECIMAL(15,2))"; - } - - /** - * A row-level expression column, such as a value read out of a JSON column. - * - * Core API releases before expression columns only know computed columns; there the - * column is still declared, it just cannot be selected on its own. - */ - protected function expression(string $name, string $sql, string $type): Column - { - // One line: whichever branch runs depends on the installed Core API, not on the test. - return method_exists(Column::class, 'expression') ? Column::expression($name, $sql, $type) : Column::computed($name, $sql, $type); - } - /** * Leave soft-deleted rows out of a table or joined relationship, on Core API releases that support it. * diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php index 124d9b0a7..cbf3838d1 100644 --- a/server/tests/ReportSchemaContractsTest.php +++ b/server/tests/ReportSchemaContractsTest.php @@ -298,7 +298,7 @@ function fleetOpsReportComputation(Column $column): ?string return fleetOpsReportCallOrProperty($column, 'getComputation', 'computation'); } -test('fleetops order report schema exposes identifiers tracking items and storefront totals', function () { +test('fleetops order report schema exposes identifiers tracking and items without assuming a meta shape', function () { $registry = new ReportSchemaRegistry(); (new FleetOpsReportSchema())->registerReportSchema($registry); @@ -316,22 +316,22 @@ function fleetOpsReportComputation(Column $column): ?string 'pod_method', 'notes', 'created_at', - 'order_total', - 'order_subtotal', - 'order_delivery_fee', - 'order_tip', - 'order_currency', - 'storefront', + 'meta', ); - // Storefront totals are read out of the order meta, in the currency's smallest unit. - expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'order_total')))->toBe("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.total')) AS DECIMAL(15,2))") - ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'storefront')))->toBe("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.storefront'))") - ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)') + expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)') ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'completed_orders')))->toBe("COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)") - ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_order_amount')))->toBe('SUM(order_total)') ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_transaction_amount')))->toBe('SUM(transaction.amount)'); + // meta has no fixed shape, so no declared column may read a key out of it; users read + // keys with computed columns instead. + $computations = array_map(fn (Column $column) => (string) fleetOpsReportComputation($column), [ + ...fleetOpsReportColumns($orders), + ...fleetOpsReportComputedColumns($orders), + ...fleetOpsReportColumnsFromRelationships(fleetOpsReportRelationships($orders)), + ]); + expect(array_filter($computations, fn (string $computation) => str_contains($computation, 'JSON_') || str_contains($computation, 'meta')))->toBe([]); + $tracking = fleetOpsReportRelationship($orders, 'tracking_number'); expect(fleetOpsReportTableName($tracking))->toBe('tracking_numbers') ->and(fleetOpsReportColumnNames($tracking))->toContain('tracking_number', 'public_id') @@ -350,9 +350,8 @@ function fleetOpsReportComputation(Column $column): ?string expect(fleetOpsReportTableName($items))->toBe('entities') ->and(fleetOpsReportCallOrProperty($items, 'getLocalKey', 'localKey'))->toBe('uuid') ->and(fleetOpsReportCallOrProperty($items, 'getForeignKey', 'foreignKey'))->toBe('payload_uuid') - ->and(fleetOpsReportColumnNames($items))->toContain('public_id', 'internal_id', 'name', 'sku', 'price', 'product_id', 'quantity', 'line_total') - ->and(fleetOpsReportComputation(fleetOpsReportColumn($items, 'quantity')))->toBe("COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.quantity')) AS DECIMAL(15,2)), 1)") - ->and(fleetOpsReportComputation(fleetOpsReportColumn($items, 'line_total')))->toBe("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.subtotal')) AS DECIMAL(15,2))") + ->and(fleetOpsReportColumnNames($items))->toContain('public_id', 'internal_id', 'name', 'sku', 'price', 'sale_price', 'meta') + ->and(fleetOpsReportColumnNames($items))->not->toContain('quantity', 'line_total', 'product_id') ->and(fleetOpsReportTableName(fleetOpsReportRelationship($items, 'destination')))->toBe('places'); // Relationship identifiers @@ -395,6 +394,5 @@ function fleetOpsReportComputation(Column $column): ?string expect($orders->usesSoftDeletes())->toBeTrue() ->and($items->usesSoftDeletes())->toBeTrue() ->and(fleetOpsReportRelationship($orders, 'customer')->usesSoftDeletes())->toBeFalse() - ->and(fleetOpsReportColumn($orders, 'order_total')->isExpression())->toBeTrue() ->and(fleetOpsReportColumn($orders, 'total_orders')->isAggregate())->toBeTrue(); }); From ebd62cf1cd830974f432b0e1639d69e320d3ea0e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 12:58:31 +0800 Subject: [PATCH 4/5] fix(reporting): drop table-name prefixes from column labels A table's own columns no longer repeat the table's name: Orders "Order ID" and "Order Type" are "ID" and "Type", and every table's public ID is "ID". Relationship labels are tuned for core-api's whole-name prefixes: "Tracking Status" (not "Tracking Status Status"), "Service Quote Amount", "Transaction Gateway ID", "Transaction Item Quantity", "Inspection Form Name", "Target Vehicle ID". --- .../Reporting/FleetOpsReportSchema.php | 52 +++++++++---------- server/tests/ReportSchemaContractsTest.php | 5 ++ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index d483de39f..2b4ff8acd 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -68,7 +68,7 @@ protected function createOrdersTable(): Table ->cacheTtl(3600) ->columns([ Column::make('public_id', 'string') - ->label('Order ID') + ->label('ID') ->description('Public order identifier') ->searchable() ->filterable() @@ -102,7 +102,7 @@ protected function createOrdersTable(): Table }), Column::make('type', 'string') - ->label('Order Type') + ->label('Type') ->description('Type of order service, e.g. transport or storefront') ->filterable() ->sortable() @@ -306,7 +306,7 @@ protected function createOrdersTable(): Table ->localKey('status_uuid') ->foreignKey('uuid') ->columns([ - Column::make('status', 'string')->label('Status'), + Column::make('status', 'string')->label('Tracking Status'), Column::make('code', 'string')->label('Code'), Column::make('details', 'string')->label('Details'), Column::make('complete', 'boolean')->label('Complete'), @@ -499,7 +499,7 @@ protected function createOrdersTable(): Table ]) ->with([ Relationship::hasAutoJoin('service_quote', 'service_quotes') - ->label('Quote') + ->label('Service Quote') ->localKey('service_quote_uuid') ->foreignKey('uuid') ->columns([ @@ -522,7 +522,7 @@ protected function createOrdersTable(): Table ->sortable(), Column::make('gateway_transaction_id', 'string') - ->label('Gateway Transaction ID') + ->label('Gateway ID') ->description('Transaction ID from payment gateway') ->searchable() ->filterable() @@ -615,47 +615,47 @@ protected function createOrdersTable(): Table ->with([ // Nested relationship for transaction items $this->softDeletes(Relationship::hasAutoJoin('items', 'transaction_items')) - ->label('Transaction Items') + ->label('Transaction Item') ->localKey('uuid') ->foreignKey('transaction_uuid') ->columns([ Column::make('description', 'string') - ->label('Item Description') + ->label('Description') ->description('Line item description') ->searchable() ->filterable(), Column::make('quantity', 'integer') - ->label('Item Quantity') + ->label('Quantity') ->description('Line item quantity') ->aggregatable() ->sortable(), Column::make('unit_price', 'integer') - ->label('Item Unit Price (minor units)') + ->label('Unit Price (minor units)') ->description('Line item unit price') ->aggregatable() ->sortable(), Column::make('amount', 'integer') - ->label('Item Amount (minor units)') + ->label('Amount (minor units)') ->description('Line item amount') ->aggregatable() ->sortable(), Column::make('currency', 'string') - ->label('Item Currency') + ->label('Currency') ->description('Line item currency code') ->filterable(), Column::make('details', 'string') - ->label('Item Details') + ->label('Details') ->description('Detailed description of the line item') ->searchable() ->filterable(), Column::make('code', 'string') - ->label('Item Code') + ->label('Code') ->description('Item or SKU code') ->searchable() ->filterable() @@ -679,7 +679,7 @@ protected function createDriversTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Driver ID') + ->label('ID') ->description('Public driver identifier') ->searchable() ->filterable() @@ -789,7 +789,7 @@ protected function createVehiclesTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Vehicle ID') + ->label('ID') ->description('Public vehicle identifier') ->searchable() ->filterable() @@ -911,7 +911,7 @@ protected function createAssetsTable(): Table ->excludeColumns(['uuid', 'company_uuid', 'deleted_at', 'meta', 'attributes']) ->maxRows(10000) ->columns([ - Column::make('public_id', 'string')->label('Asset ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('asset_class', 'string')->label('Asset Class')->filterable()->aggregatable(), Column::make('name', 'string')->label('Name')->searchable()->filterable()->sortable(), Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(), @@ -947,7 +947,7 @@ protected function createPlacesTable(): Table ->maxRows(100000) ->columns([ Column::make('public_id', 'string') - ->label('Place ID') + ->label('ID') ->description('Public place identifier') ->searchable() ->filterable() @@ -1013,7 +1013,7 @@ protected function createContactsTable(): Table ->maxRows(50000) ->columns([ Column::make('public_id', 'string') - ->label('Contact ID') + ->label('ID') ->description('Public contact identifier') ->searchable() ->filterable() @@ -1069,7 +1069,7 @@ protected function createVendorsTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Vendor ID') + ->label('ID') ->description('Public vendor identifier') ->searchable() ->filterable() @@ -1125,7 +1125,7 @@ protected function createFuelReportsTable(): Table ->maxRows(100000) ->columns([ Column::make('public_id', 'string') - ->label('Report ID') + ->label('ID') ->description('Public fuel report identifier') ->searchable() ->filterable() @@ -1236,7 +1236,7 @@ protected function createWorkOrdersTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'checklist', 'cost_breakdown']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Work Order ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(), Column::make('subject', 'string')->label('Subject')->searchable()->filterable()->sortable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), @@ -1263,7 +1263,7 @@ protected function createWorkOrdersTable(): Table ->localKey('target_uuid') ->foreignKey('uuid') ->columns([ - Column::make('public_id', 'string')->label('Vehicle ID'), + Column::make('public_id', 'string')->label('ID'), Column::make('plate_number', 'string')->label('Plate Number'), Column::make('make', 'string')->label('Make'), Column::make('model', 'string')->label('Model'), @@ -1285,7 +1285,7 @@ protected function createMaintenancesTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'line_items', 'attachments']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Maintenance ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(), @@ -1346,7 +1346,7 @@ protected function createInspectionSubmissionsTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'location', 'signature', 'attachments']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Inspection ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), Column::make('result', 'string')->label('Result')->filterable()->sortable()->aggregatable(), @@ -1371,8 +1371,8 @@ protected function createInspectionSubmissionsTable(): Table ->localKey('inspection_form_uuid') ->foreignKey('uuid') ->columns([ - Column::make('name', 'string')->label('Form Name'), - Column::make('type', 'string')->label('Form Type'), + Column::make('name', 'string')->label('Name'), + Column::make('type', 'string')->label('Type'), ]), Relationship::hasAutoJoin('vehicle', 'vehicles') ->label('Vehicle') diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php index cbf3838d1..afdae61aa 100644 --- a/server/tests/ReportSchemaContractsTest.php +++ b/server/tests/ReportSchemaContractsTest.php @@ -319,6 +319,11 @@ function fleetOpsReportComputation(Column $column): ?string 'meta', ); + // Columns of the table itself are not prefixed with the table's name ("Order ID" → "ID"). + $label = fn (Column $column) => fleetOpsReportCallOrProperty($column, 'getLabel', 'label'); + expect($label(fleetOpsReportColumn($orders, 'public_id')))->toBe('ID') + ->and($label(fleetOpsReportColumn($orders, 'type')))->toBe('Type'); + expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)') ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'completed_orders')))->toBe("COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)") ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_transaction_amount')))->toBe('SUM(transaction.amount)'); From 65e46e869ae60cd2db804e826357ce986128e5c4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 15:29:57 +0800 Subject: [PATCH 5/5] feat(dashboard): clean default layout and live-map cards in the Live Fleet widget Default dashboard - Fleet-ops widgets declare an `order` so the shared default dashboard lays out cleanly beside other extensions' widgets: the KPI row (Radar, Active Orders, Drivers Online, with ledger's Revenue), ledger's KPI row, the Live Fleet Map at full width, then Revenue Trend, Top Drivers and Maintenance Overview a third of the width each. - Earnings and Avg Order Value are no longer defaults (still addable). Live Fleet widget - Its marker popups and hover tooltips are now the live map's own cards: the driver and vehicle cards moved into shared Map::MarkerCard::Driver/Vehicle components (the live map rendered each twice, inline), and the widget uses them too. - /fleet-ops/analytics/live-fleet adds each marker's `card`, the same index resource the live map's endpoints return (status, driver, trailers, devices, order, speed, heading, location labels). --- addon/components/map/leaflet-live-map.hbs | 246 +----------------- addon/components/map/marker-card/driver.hbs | 57 ++++ addon/components/map/marker-card/driver.js | 3 + addon/components/map/marker-card/vehicle.hbs | 78 ++++++ addon/components/map/marker-card/vehicle.js | 3 + addon/components/widget/live-fleet.hbs | 48 +--- addon/extension.js | 27 +- app/components/map/marker-card/driver.js | 1 + app/components/map/marker-card/vehicle.js | 1 + server/src/Support/Analytics/LiveFleet.php | 20 +- server/tests/AnalyticsRoutesTest.php | 31 ++- .../Unit/Support/Analytics/LiveFleetTest.php | 6 + .../components/map/marker-card/driver-test.js | 35 +++ .../map/marker-card/vehicle-test.js | 47 ++++ tests/unit/extension-test.js | 36 +++ 15 files changed, 347 insertions(+), 292 deletions(-) create mode 100644 addon/components/map/marker-card/driver.hbs create mode 100644 addon/components/map/marker-card/driver.js create mode 100644 addon/components/map/marker-card/vehicle.hbs create mode 100644 addon/components/map/marker-card/vehicle.js create mode 100644 app/components/map/marker-card/driver.js create mode 100644 app/components/map/marker-card/vehicle.js create mode 100644 tests/integration/components/map/marker-card/driver-test.js create mode 100644 tests/integration/components/map/marker-card/vehicle-test.js diff --git a/addon/components/map/leaflet-live-map.hbs b/addon/components/map/leaflet-live-map.hbs index a82b064b5..920918b44 100644 --- a/addon/components/map/leaflet-live-map.hbs +++ b/addon/components/map/leaflet-live-map.hbs @@ -83,108 +83,10 @@ as |marker| > -
-
- - {{driver.name}} -
- {{n-a driver.meta.status_label (smart-humanize driver.status)}} -
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a - driver.meta.location_coordinates - (point-coordinates driver.location) - }}
-
-
-
+
-
-
- - {{driver.name}} -
- {{n-a driver.meta.status_label (smart-humanize driver.status)}} -
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a - driver.meta.location_coordinates - (point-coordinates driver.location) - }}
-
-
-
+
{{/each}} @@ -202,150 +104,10 @@ as |marker| > -
-
- - {{vehicle.displayName}} -
- {{n-a vehicle.meta.status_label}} -
-
-
-
-
Vehicle #
-
{{n-a - vehicle.internal_id - vehicle.public_id - vehicle.plate_number - vehicle.serial_number - vehicle.vin - }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}} -
- - {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}} -
- - {{or device.displayName device.display_name device.name device.device_id device.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
-
-
- - {{vehicle.displayName}} -
- {{n-a vehicle.meta.status_label}} -
-
-
-
-
Vehicle #
-
{{n-a - vehicle.internal_id - vehicle.public_id - vehicle.plate_number - vehicle.serial_number - vehicle.vin - }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}} -
- - {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}} -
- - {{or device.displayName device.display_name device.name device.device_id device.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
{{/each}} diff --git a/addon/components/map/marker-card/driver.hbs b/addon/components/map/marker-card/driver.hbs new file mode 100644 index 000000000..ff15f7e6d --- /dev/null +++ b/addon/components/map/marker-card/driver.hbs @@ -0,0 +1,57 @@ +{{! + The driver card the fleet-ops live map shows in a marker's popup and hover tooltip. + Shared with the dashboard's Live Fleet widget so both look the same. @driver is a driver + model or its index resource payload (the same attributes, plain JSON). + @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel). +}} +
+
+ + {{@driver.name}} +
+ {{n-a @driver.meta.status_label (smart-humanize @driver.status)}} +
+
+
+
+
ID
+
{{n-a @driver.public_id @driver.id}}
+
+
+
Phone
+
{{n-a @driver.phone}}
+
+
+
Vehicle
+
{{n-a @driver.vehicle_name}}
+
+
+
Email
+
{{n-a @driver.email}}
+
+
+
Order
+
{{n-a @driver.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @driver.meta.speed_label}}
+
+
+
Heading
+
{{n-a @driver.meta.heading_label}}
+
+
+
Location
+
{{n-a + @driver.meta.location_coordinates + (point-coordinates @driver.location) + }}
+
+
+
+ diff --git a/addon/components/map/marker-card/driver.js b/addon/components/map/marker-card/driver.js new file mode 100644 index 000000000..fa8505356 --- /dev/null +++ b/addon/components/map/marker-card/driver.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/map/marker-card/vehicle.hbs b/addon/components/map/marker-card/vehicle.hbs new file mode 100644 index 000000000..a6f5544cc --- /dev/null +++ b/addon/components/map/marker-card/vehicle.hbs @@ -0,0 +1,78 @@ +{{! + The vehicle card the fleet-ops live map shows in a marker's popup and hover tooltip. + Shared with the dashboard's Live Fleet widget so both look the same. @vehicle is a vehicle + model or its index resource payload (the same attributes, plain JSON). + @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel). +}} +
+
+ + {{or @vehicle.displayName @vehicle.display_name @vehicle.name}} +
+ {{n-a @vehicle.meta.status_label}} +
+
+
+
+
Vehicle #
+
{{n-a + @vehicle.internal_id + @vehicle.public_id + @vehicle.plate_number + @vehicle.serial_number + @vehicle.vin + }}
+
+
+
Driver
+
{{n-a @vehicle.driver_name}}
+
+
+
{{t "resource.trailers"}}
+
+ {{#each @vehicle.trailers as |trailer|}} +
+ + {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
{{t "resource.devices"}}
+
+ {{#each @vehicle.devices as |device|}} +
+ + {{or device.displayName device.display_name device.name device.device_id device.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
Order
+
{{n-a @vehicle.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @vehicle.meta.speed_label}}
+
+
+
Heading
+
{{n-a @vehicle.meta.heading_label}}
+
+
+
Location
+
{{n-a @vehicle.meta.location_coordinates}}
+
+
+
+ diff --git a/addon/components/map/marker-card/vehicle.js b/addon/components/map/marker-card/vehicle.js new file mode 100644 index 000000000..fa8505356 --- /dev/null +++ b/addon/components/map/marker-card/vehicle.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/widget/live-fleet.hbs b/addon/components/widget/live-fleet.hbs index 82965680b..01ef5974d 100644 --- a/addon/components/widget/live-fleet.hbs +++ b/addon/components/widget/live-fleet.hbs @@ -28,7 +28,8 @@ {{#if this.load.isIdle}} {{! Drivers — fleetops' rotating tracking-marker matches the operational - live-map's look and animates on heading changes. }} + live-map's look and animates on heading changes. The popup and tooltip + are the live map's own cards, fed from each marker's index-resource card. }} {{#each this.drivers as |driver|}} - -
-
- {{driver.name}} -
-
-
{{or driver.name "Driver"}}
-
- {{#if driver.current_order_uuid}} - On order - {{else if driver.online}} - Online - {{else}} - Offline - {{/if}} -
-
{{driver.lat}}, {{driver.lng}}
-
-
+ + -
{{or driver.name "Driver"}}
+
{{/each}} @@ -77,27 +61,11 @@ @draggable={{false}} as |marker| > - -
-
- {{vehicle.name}} -
-
-
{{or vehicle.name "Vehicle"}}
- {{#if vehicle.plate_number}} -
{{vehicle.plate_number}}
- {{/if}} - {{#if vehicle.driver_name}} -
- {{vehicle.driver_name}} -
- {{/if}} -
{{vehicle.lat}}, {{vehicle.lng}}
-
-
+ + -
{{or vehicle.name "Vehicle"}}
+
{{/each}} diff --git a/addon/extension.js b/addon/extension.js index bcf5b4602..925f632f9 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -161,6 +161,14 @@ export default { }, registerWidgets(widgetService) { + // `order` places a default widget on the default dashboard, which mixes widgets from + // every extension; the grid fills rows first-fit, lowest order first (widgets without + // one follow). The default layout is: + // 10-40 KPI row: Radar, Revenue (ledger, 20), Active Orders, Drivers Online + // 41-44 Ledger KPI row: Expenses, Net Income, Outstanding AR, Overdue AR + // 50 Live Fleet Map, full width + // 60-80 Revenue Trend, Top Drivers, Maintenance Overview, a third of the width each + // 150-170 Ledger Recent Financial Activity + Cash Flow Summary | console Blog + GitHub const widgets = [ // Legacy monolithic 13-tile widget — kept registered for one release as // users have it pinned to existing dashboards. The new KPI tile widgets @@ -186,7 +194,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-earnings'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', - default: true, + default: false, }), new Widget({ id: 'fleet-ops-kpi-aov-widget', @@ -196,7 +204,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-aov'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', - default: true, + default: false, }), new Widget({ id: 'fleet-ops-kpi-distance-widget', @@ -216,6 +224,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-active-orders'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 30, default: true, }), new Widget({ @@ -226,6 +235,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-drivers-online'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 40, default: true, }), new Widget({ @@ -236,6 +246,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/radar'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 10, default: true, }), new Widget({ @@ -266,8 +277,9 @@ export default { description: 'Real-time driver positions and active routes.', icon: 'map-location-dot', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'), - grid_options: { w: 8, h: 11, minW: 8, minH: 8 }, + grid_options: { w: 12, h: 12, minW: 8, minH: 8 }, category: 'Maps', + order: 50, default: true, }), new Widget({ @@ -276,8 +288,9 @@ export default { description: 'Revenue over time with period comparison.', icon: 'chart-line', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/revenue-trend'), - grid_options: { w: 4, h: 11, minW: 4, minH: 8 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 7 }, category: 'Analytics', + order: 60, default: true, }), new Widget({ @@ -306,8 +319,9 @@ export default { description: 'Driver leaderboard sortable by orders, on-time %, or distance.', icon: 'medal', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/top-drivers'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 6 }, category: 'Analytics', + order: 70, default: true, }), new Widget({ @@ -346,8 +360,9 @@ export default { description: 'Overdue, scheduled, and YTD maintenance spend.', icon: 'wrench', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/maintenance-overview'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 6 }, category: 'Analytics', + order: 80, default: true, }), new Widget({ diff --git a/app/components/map/marker-card/driver.js b/app/components/map/marker-card/driver.js new file mode 100644 index 000000000..233ee41cb --- /dev/null +++ b/app/components/map/marker-card/driver.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/driver'; diff --git a/app/components/map/marker-card/vehicle.js b/app/components/map/marker-card/vehicle.js new file mode 100644 index 000000000..524d8ad84 --- /dev/null +++ b/app/components/map/marker-card/vehicle.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/vehicle'; diff --git a/server/src/Support/Analytics/LiveFleet.php b/server/src/Support/Analytics/LiveFleet.php index 9cfbccca6..1eff6bd5d 100644 --- a/server/src/Support/Analytics/LiveFleet.php +++ b/server/src/Support/Analytics/LiveFleet.php @@ -2,10 +2,13 @@ namespace Fleetbase\FleetOps\Support\Analytics; +use Fleetbase\FleetOps\Http\Resources\v1\Index\Driver as DriverIndexResource; +use Fleetbase\FleetOps\Http\Resources\v1\Index\Vehicle as VehicleIndexResource; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Support\Metrics\OrdersInProgressMetric; +use Illuminate\Http\Resources\Json\JsonResource; /** * Real-time fleet snapshot used as the initial-state payload for Live Fleet Map. @@ -23,7 +26,8 @@ public function get(): array // Driver.name is a virtual attribute backed by users.name — eager-load the // user so the accessor resolves cleanly without a per-record query. - $drivers = Driver::with(['user:uuid,name,avatar_uuid']) + // The user and vehicle also feed each marker's card (see card()). + $drivers = Driver::with(['user', 'vehicle']) ->where('drivers.company_uuid', $companyUuid) ->whereNotNull('drivers.location') ->where(function ($q) { @@ -33,7 +37,8 @@ public function get(): array // Vehicles tracked independently of drivers (telematics-connected rigs may // report position without an active driver session). - $vehicles = Vehicle::where('company_uuid', $companyUuid) + $vehicles = Vehicle::with(['devices', 'driver', 'currentTrailers']) + ->where('company_uuid', $companyUuid) ->whereNotNull('location') ->get(); @@ -79,6 +84,7 @@ private function driverPayload(Driver $d): array 'lat' => $lat, 'lng' => $lng, 'updated_at' => $d->last_location_update_at, + 'card' => $this->card(new DriverIndexResource($d)), ]; } @@ -98,9 +104,19 @@ private function vehiclePayload(Vehicle $v): array 'heading' => (float) ($v->heading ?? 0), 'lat' => $lat, 'lng' => $lng, + 'card' => $this->card(new VehicleIndexResource($v)), ]; } + /** + * What the operational live map shows in a marker's popup and tooltip: the same index + * resource its live endpoints return, so the dashboard widget renders identical cards. + */ + private function card(JsonResource $resource): array + { + return $resource->resolve(request()); + } + /** * Pull [lat, lng] out of whatever shape the spatial accessor handed us. * Supports Point objects, [lat, lng] arrays, and {lat, lng} hashes. diff --git a/server/tests/AnalyticsRoutesTest.php b/server/tests/AnalyticsRoutesTest.php index 41048f9bf..07f243c14 100644 --- a/server/tests/AnalyticsRoutesTest.php +++ b/server/tests/AnalyticsRoutesTest.php @@ -17,6 +17,9 @@ use Fleetbase\FleetOps\Support\Analytics\TopDrivers; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; +use Illuminate\Database\ConnectionResolver; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -273,6 +276,16 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request }); test('live fleet analytics serializes driver and vehicle map payloads', function () { + // The markers' cards look up the current order and last known position. + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, vehicle_assigned_uuid varchar(64) null, tracking_number_uuid varchar(64) null, status varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, user_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table users (uuid varchar(64) primary key, company_uuid varchar(64) null, name varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + $analytics = new LiveFleet(); $driver = new TestFleetOpsLiveFleetDriver(); @@ -308,7 +321,21 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request $driverPayload->setAccessible(true); $vehiclePayload->setAccessible(true); - expect($driverPayload->invoke($analytics, $driver))->toBe([ + $driverResult = $driverPayload->invoke($analytics, $driver); + $vehicleResult = $vehiclePayload->invoke($analytics, $vehicle); + + // Each marker carries the live map's card: the same index resource the live endpoints return. + expect($driverResult['card'])->toBeArray() + ->and($driverResult['card'])->toHaveKeys(['name', 'status', 'phone', 'email', 'online', 'meta']) + ->and($driverResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']) + ->and($driverResult['card']['name'])->toBe('Ada Driver') + ->and($vehicleResult['card'])->toHaveKeys(['display_name', 'driver_name', 'plate_number', 'online', 'meta']) + ->and($vehicleResult['card']['display_name'])->toBe('Van 7') + ->and($vehicleResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']); + + unset($driverResult['card'], $vehicleResult['card']); + + expect($driverResult)->toBe([ 'uuid' => 'driver-uuid', 'public_id' => 'driver-public', 'name' => 'Ada Driver', @@ -320,7 +347,7 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request 'lat' => 1.30, 'lng' => 103.80, 'updated_at' => '2026-01-01 10:00:00', - ])->and($vehiclePayload->invoke($analytics, $vehicle))->toBe([ + ])->and($vehicleResult)->toBe([ 'uuid' => 'vehicle-uuid', 'public_id' => 'vehicle-public', 'name' => 'Van 7', diff --git a/server/tests/Unit/Support/Analytics/LiveFleetTest.php b/server/tests/Unit/Support/Analytics/LiveFleetTest.php index e40deac5d..b535f6778 100644 --- a/server/tests/Unit/Support/Analytics/LiveFleetTest.php +++ b/server/tests/Unit/Support/Analytics/LiveFleetTest.php @@ -38,6 +38,12 @@ function fleetopsLiveFleetUseInMemoryConnection(): SQLiteConnection $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, user_uuid varchar(64) null, company_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, avatar_url varchar(255) null, location blob null, heading numeric null, online integer null, last_location_update_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table vehicles (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_uuid varchar(64) null, photo_uuid varchar(64) null, avatar_url varchar(255) null, name varchar(255) null, year integer null, make varchar(255) null, model varchar(255) null, trim varchar(255) null, plate_number varchar(64) null, location blob null, heading numeric null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, internal_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, status varchar(64) null, tracking_number_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + // Tables the markers' index-resource cards read: devices, coupled trailers and positions. + $connection->statement('create table devices (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, attachable_uuid varchar(64) null, attachable_type varchar(255) null, name varchar(255) null, device_id varchar(64) null, serial_number varchar(64) null, imei varchar(64) null, provider varchar(64) null, status varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table asset_connections (uuid varchar(64) primary key, company_uuid varchar(64) null, connector_uuid varchar(64) null, connector_type varchar(255) null, connected_uuid varchar(64) null, connected_type varchar(255) null, connected_at datetime null, disconnected_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table assets (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, asset_class varchar(64) null, name varchar(255) null, code varchar(64) null, type varchar(64) null, status varchar(64) null, plate_number varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, coordinates blob null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table tracking_numbers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, owner_uuid varchar(64) null, owner_type varchar(255) null, status_uuid varchar(64) null, tracking_number varchar(64) null, region varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table files (uuid varchar(64) primary key, type varchar(64) null, url varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $resolver = new ConnectionResolver([ diff --git a/tests/integration/components/map/marker-card/driver-test.js b/tests/integration/components/map/marker-card/driver-test.js new file mode 100644 index 000000000..17d6a624c --- /dev/null +++ b/tests/integration/components/map/marker-card/driver-test.js @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | map/marker-card/driver', function (hooks) { + setupRenderingTest(hooks); + + test('it shows the driver card from an index-resource payload', async function (assert) { + this.set('driver', { + name: 'Ada Driver', + online: true, + status: 'active', + public_id: 'driver_public', + phone: '+6590000000', + vehicle_name: 'Van 7', + email: 'ada@example.test', + meta: { status_label: 'Active', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' }, + }); + + await render(hbs``); + + for (const text of ['Ada Driver', 'Active', 'driver_public', '+6590000000', 'Van 7', 'ada@example.test', 'ORD-1', '42 km/h', '90 deg', '1.3 103.8']) { + assert.dom(this.element).containsText(text); + } + }); + + test('the tooltip variant drops the dark panel', async function (assert) { + this.set('driver', { name: 'Ada Driver', meta: {} }); + + await render(hbs``); + + assert.dom('div').doesNotHaveClass('bg-gray-900'); + }); +}); diff --git a/tests/integration/components/map/marker-card/vehicle-test.js b/tests/integration/components/map/marker-card/vehicle-test.js new file mode 100644 index 000000000..6bc4fd06d --- /dev/null +++ b/tests/integration/components/map/marker-card/vehicle-test.js @@ -0,0 +1,47 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +// The live map passes a vehicle model; the dashboard's Live Fleet widget passes the same +// index-resource payload as plain JSON. Both must render the same card. +const VEHICLE = { + display_name: 'Van 7', + online: true, + status: 'in_service', + internal_id: 'V-7', + driver_name: 'Ada Driver', + trailers: [{ display_name: 'Reefer 2', online: true }], + devices: [], + meta: { status_label: 'In Service', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' }, +}; + +module('Integration | Component | map/marker-card/vehicle', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it shows the vehicle card from an index-resource payload', async function (assert) { + this.set('vehicle', VEHICLE); + + await render(hbs``); + + assert.dom(this.element).containsText('Van 7', 'the display name falls back to display_name'); + assert.dom(this.element).containsText('In Service'); + assert.dom(this.element).containsText('V-7'); + assert.dom(this.element).containsText('Ada Driver'); + assert.dom(this.element).containsText('Reefer 2'); + assert.dom(this.element).containsText('ORD-1'); + assert.dom(this.element).containsText('42 km/h'); + assert.dom('div').hasClass('bg-gray-900', 'a popup card is its own dark panel'); + }); + + test('a model display name wins and the tooltip variant drops the panel', async function (assert) { + this.set('vehicle', { ...VEHICLE, displayName: 'Van 7 (SBA1234Z)' }); + + await render(hbs``); + + assert.dom(this.element).containsText('Van 7 (SBA1234Z)'); + assert.dom('div').doesNotHaveClass('bg-gray-900', 'the tooltip supplies its own panel'); + }); +}); diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js index 99106b422..bb568a351 100644 --- a/tests/unit/extension-test.js +++ b/tests/unit/extension-test.js @@ -89,4 +89,40 @@ module('Unit | FleetOps extension', function () { 'default analytics widgets are available' ); }); + + test('it lays out the default dashboard: KPI row, full-width map, then three panels', function (assert) { + let registered = []; + extension.registerWidgets({ + registerDashboard() {}, + registerWidgets(id, widgets) { + if (id === 'dashboard') registered = widgets.map((widget) => widget.toObject()); + }, + registerDefaultWidgets() {}, + }); + + const byId = Object.fromEntries(registered.map((widget) => [widget.id, widget])); + const defaults = registered.filter((widget) => widget.default === true).sort((a, b) => a.order - b.order); + + assert.deepEqual( + defaults.map((widget) => [widget.id, widget.order]), + [ + ['fleet-ops-radar-widget', 10], + ['fleet-ops-kpi-active-orders-widget', 30], + ['fleet-ops-kpi-drivers-online-widget', 40], + ['fleet-ops-live-fleet-widget', 50], + ['fleet-ops-revenue-trend-widget', 60], + ['fleet-ops-top-drivers-widget', 70], + ['fleet-ops-maintenance-overview-widget', 80], + ], + 'every default widget has a place, leaving 20 for the ledger Revenue tile' + ); + assert.strictEqual(byId['fleet-ops-live-fleet-widget'].grid_options.w, 12, 'the map spans the full width'); + assert.deepEqual( + ['fleet-ops-revenue-trend-widget', 'fleet-ops-top-drivers-widget', 'fleet-ops-maintenance-overview-widget'].map((id) => byId[id].grid_options.w), + [4, 4, 4], + 'the panels share a row' + ); + assert.false(byId['fleet-ops-kpi-earnings-widget'].default, 'Earnings is no longer a default'); + assert.false(byId['fleet-ops-kpi-aov-widget'].default, 'Avg Order Value is no longer a default'); + }); });