- {{#each @resource.parcel_fees as |parcelFee|}}
+ {{#each @resource.parcelFees as |parcelFee|}}
@@ -541,4 +554,4 @@
{{/if}}
-
\ No newline at end of file
+
diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js
index 92596e3a..f3adf0e1 100644
--- a/addon/controllers/operations/orders/index/details.js
+++ b/addon/controllers/operations/orders/index/details.js
@@ -16,6 +16,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
@service universe;
@service sidebar;
@tracked routingControl;
+ @tracked routingCompleted = false;
get tabs() {
const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:order:details');
@@ -109,7 +110,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
@action async setup() {
// Change to map layout and display order route
this.index.changeLayout('map');
- this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints);
+ this.routingCompleted = false;
+ this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints, {
+ onRouteFound: () => {
+ this.routingCompleted = true;
+ },
+ onRoutingError: () => {
+ this.routingCompleted = true;
+ },
+ });
+
+ if (!this.routingControl) {
+ this.routingCompleted = true;
+ }
// Hide sidebar
this.sidebar.hideNow();
@@ -125,7 +138,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
async (_msg, { reloadable }) => {
if (reloadable) {
await this.hostRouter.refresh();
- this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl);
+ this.routingCompleted = false;
+ this.routingControl = await this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl, {
+ onRouteFound: () => {
+ this.routingCompleted = true;
+ },
+ onRoutingError: () => {
+ this.routingCompleted = true;
+ },
+ });
+
+ if (!this.routingControl) {
+ this.routingCompleted = true;
+ }
}
},
{ debounceMs: 250 }
diff --git a/addon/controllers/operations/orders/index/details/virtual.js b/addon/controllers/operations/orders/index/details/virtual.js
new file mode 100644
index 00000000..6caa543d
--- /dev/null
+++ b/addon/controllers/operations/orders/index/details/virtual.js
@@ -0,0 +1,5 @@
+import Controller, { inject as controller } from '@ember/controller';
+
+export default class OperationsOrdersIndexDetailsVirtualController extends Controller {
+ @controller('operations.orders.index.details') parent;
+}
diff --git a/addon/controllers/operations/service-rates/index/edit.js b/addon/controllers/operations/service-rates/index/edit.js
index 4d2d39ce..54ddf4ee 100644
--- a/addon/controllers/operations/service-rates/index/edit.js
+++ b/addon/controllers/operations/service-rates/index/edit.js
@@ -22,6 +22,7 @@ export default class OperationsServiceRatesIndexEditController extends Controlle
@task *save(serviceRate) {
try {
yield serviceRate.save();
+ yield serviceRate.reload();
this.events.trackResourceUpdated(serviceRate);
this.overlay?.close();
diff --git a/addon/controllers/operations/service-rates/index/new.js b/addon/controllers/operations/service-rates/index/new.js
index eac50956..f023e13a 100644
--- a/addon/controllers/operations/service-rates/index/new.js
+++ b/addon/controllers/operations/service-rates/index/new.js
@@ -16,6 +16,7 @@ export default class OperationsServiceRatesIndexNewController extends Controller
@task *save(serviceRate) {
try {
yield serviceRate.save();
+ yield serviceRate.reload();
this.events.trackResourceCreated(serviceRate);
this.overlay?.close();
diff --git a/addon/helpers/f-to-int.js b/addon/helpers/f-to-int.js
new file mode 100644
index 00000000..82caf6be
--- /dev/null
+++ b/addon/helpers/f-to-int.js
@@ -0,0 +1,6 @@
+import { helper } from '@ember/component/helper';
+import numbersOnly from '@fleetbase/ember-core/utils/numbers-only';
+
+export default helper(function fToInt([value]) {
+ return parseInt(numbersOnly(value));
+});
diff --git a/addon/routes/application.js b/addon/routes/application.js
index 27507def..4b3ad0c7 100644
--- a/addon/routes/application.js
+++ b/addon/routes/application.js
@@ -20,14 +20,14 @@ export default class ApplicationRoute extends Route {
});
}
- beforeModel() {
+ async beforeModel() {
if (this.abilities.cannot('fleet-ops see extension')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
return this.hostRouter.transitionTo('console');
}
- this.location.getUserLocation();
- this.#loadRoutingSettings();
+ await this.location.getUserLocation();
+ await this.#loadRoutingSettings();
}
async #loadRoutingSettings() {
diff --git a/addon/routes/operations/orders/index/details.js b/addon/routes/operations/orders/index/details.js
index 210b9922..49c72aca 100644
--- a/addon/routes/operations/orders/index/details.js
+++ b/addon/routes/operations/orders/index/details.js
@@ -15,9 +15,12 @@ export default class OperationsOrdersIndexDetailsRoute extends Route {
@action willTransition(transition) {
const fromName = transition.from?.name;
const toName = transition.to?.name;
+ const orderDetailsRoute = 'console.fleet-ops.operations.orders.index.details';
- // only cleanup when actually leaving this route (not intra-route changes)
- if (fromName && fromName !== toName) {
+ // only cleanup when leaving the order details route tree entirely
+ const isLeavingOrderDetails = fromName?.startsWith(orderDetailsRoute) && !toName?.startsWith(orderDetailsRoute);
+
+ if (isLeavingOrderDetails) {
const controller = this.controllerFor('operations.orders.index.details');
const rc = controller.routingControl;
@@ -55,7 +58,20 @@ export default class OperationsOrdersIndexDetailsRoute extends Route {
return this.store.queryRecord('order', {
public_id,
single: true,
- with: ['payload', 'driverAssigned', 'orderConfig', 'customer', 'facilitator', 'trackingStatuses', 'trackingNumber', 'purchaseRate', 'comments', 'files'],
+ with: [
+ 'payload',
+ 'driverAssigned',
+ 'orderConfig',
+ 'customer',
+ 'facilitator',
+ 'trackingStatuses',
+ 'trackingNumber',
+ 'purchaseRate',
+ 'purchaseRate.serviceQuote',
+ 'purchaseRate.serviceQuote.items',
+ 'comments',
+ 'files',
+ ],
});
}
diff --git a/addon/routes/operations/orders/index/details/virtual.js b/addon/routes/operations/orders/index/details/virtual.js
index 0c504634..b7331fd3 100644
--- a/addon/routes/operations/orders/index/details/virtual.js
+++ b/addon/routes/operations/orders/index/details/virtual.js
@@ -19,5 +19,6 @@ export default class OperationsOrdersIndexDetailsVirtualRoute extends Route {
setupController(controller) {
super.setupController(...arguments);
controller.resource = this.modelFor('operations.orders.index.details');
+ controller.detailsController = this.controllerFor('operations.orders.index.details');
}
}
diff --git a/addon/services/equipment-actions.js b/addon/services/equipment-actions.js
index f6e5927e..29f404e7 100644
--- a/addon/services/equipment-actions.js
+++ b/addon/services/equipment-actions.js
@@ -1,7 +1,9 @@
-import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
export default class EquipmentActionsService extends ResourceActionService {
- @service currentUser;
+ get defaultCurrency() {
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
+ }
constructor() {
super(...arguments);
@@ -12,10 +14,6 @@ export default class EquipmentActionsService extends ResourceActionService {
});
}
- get defaultCurrency() {
- return this.currentUser?.company?.currency || 'USD';
- }
-
transition = {
view: (equipment) => this.transitionTo('maintenance.equipment.index.details', equipment),
edit: (equipment) => this.transitionTo('maintenance.equipment.index.edit', equipment),
diff --git a/addon/services/leaflet-map-manager.js b/addon/services/leaflet-map-manager.js
index 3000351e..036eebe6 100644
--- a/addon/services/leaflet-map-manager.js
+++ b/addon/services/leaflet-map-manager.js
@@ -270,6 +270,10 @@ export default class LeafletMapManagerService extends Service {
this.route = routes[0];
});
+ routingControl.on('routingerror', (event) => {
+ options?.onRoutingError?.(event);
+ });
+
this.positionWaypoints(waypoints);
return routingControl;
diff --git a/addon/services/maintenance-actions.js b/addon/services/maintenance-actions.js
index 3f5b97a0..a1759b29 100644
--- a/addon/services/maintenance-actions.js
+++ b/addon/services/maintenance-actions.js
@@ -1,7 +1,9 @@
-import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
export default class MaintenanceActionsService extends ResourceActionService {
- @service currentUser;
+ get defaultCurrency() {
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
+ }
constructor() {
super(...arguments);
@@ -12,10 +14,6 @@ export default class MaintenanceActionsService extends ResourceActionService {
});
}
- get defaultCurrency() {
- return this.currentUser?.company?.currency || 'USD';
- }
-
transition = {
view: (maintenance) => this.transitionTo('maintenance.maintenances.index.details', maintenance),
edit: (maintenance) => this.transitionTo('maintenance.maintenances.index.edit', maintenance),
diff --git a/addon/services/part-actions.js b/addon/services/part-actions.js
index 44fc9629..d6d579eb 100644
--- a/addon/services/part-actions.js
+++ b/addon/services/part-actions.js
@@ -1,7 +1,9 @@
-import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
export default class PartActionsService extends ResourceActionService {
- @service currentUser;
+ get defaultCurrency() {
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
+ }
constructor() {
super(...arguments);
@@ -12,10 +14,6 @@ export default class PartActionsService extends ResourceActionService {
});
}
- get defaultCurrency() {
- return this.currentUser?.company?.currency || 'USD';
- }
-
transition = {
view: (part) => this.transitionTo('maintenance.parts.index.details', part),
edit: (part) => this.transitionTo('maintenance.parts.index.edit', part),
diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js
index 4d5cdc2c..72dccf77 100644
--- a/addon/services/service-rate-actions.js
+++ b/addon/services/service-rate-actions.js
@@ -6,13 +6,17 @@ import serializePayload from '../utils/serialize-payload';
export default class ServiceRateActionsService extends ResourceActionService {
modelNamePath = 'service_name';
+ get defaultCurrency() {
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
+ }
+
constructor() {
super(...arguments);
this.initialize('service-rate', {
defaultAttributes: {
rate_calculation_method: 'per_meter',
per_meter_unit: 'm',
- currency: this.currentUser.currency,
+ currency: this.defaultCurrency,
parcel_fees: this.#getDefaultParcelFees(),
},
});
@@ -118,12 +122,13 @@ export default class ServiceRateActionsService extends ResourceActionService {
const hasFacilitator = !isNone(order.facilitator);
const facilitatorServiceType = order.facilitator?.get('service_types.firstObject.key') ?? order.type;
+ const routeSummary = order.route?.summary ?? order.route?.details?.summary ?? {};
try {
const serviceQuotes = yield this.fetch.post('service-quotes/preliminary', {
payload: serializePayload(order.payload),
- distance: order.route.summary?.totalDistance,
- time: order.route.summary?.totalTime,
+ distance: routeSummary.totalDistance,
+ time: routeSummary.totalTime,
service_type: hasFacilitator ? facilitatorServiceType : order.type,
facilitator: order.facilitator?.public_id,
scheduled_at: order.scheduled_at,
diff --git a/addon/templates/operations/orders/index/details/virtual.hbs b/addon/templates/operations/orders/index/details/virtual.hbs
index 2977428d..b2213fad 100644
--- a/addon/templates/operations/orders/index/details/virtual.hbs
+++ b/addon/templates/operations/orders/index/details/virtual.hbs
@@ -1 +1,7 @@
-{{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}}
\ No newline at end of file
+{{#if this.parent.routingCompleted}}
+ {{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}}
+{{else}}
+
+
+
+{{/if}}
diff --git a/addon/utils/fleet-ops-options.js b/addon/utils/fleet-ops-options.js
index 5ff3de08..f6c7ee1f 100644
--- a/addon/utils/fleet-ops-options.js
+++ b/addon/utils/fleet-ops-options.js
@@ -123,6 +123,38 @@ export const contactTypes = [
{ label: 'Dispatcher', value: 'dispatcher', description: 'Scheduling and operations contact' },
];
+export const entityTypes = [
+ { label: 'Parcel', value: 'parcel', description: 'Standard parcel or package used by parcel-based pricing.' },
+ { label: 'Package', value: 'package', description: 'Generic package or boxed item.' },
+ { label: 'Product', value: 'product', description: 'Sellable product or catalog item.' },
+ { label: 'Item', value: 'item', description: 'Generic inventory item when no stronger type applies.' },
+ { label: 'SKU / Inventory Unit', value: 'sku', description: 'Stock keeping unit or tracked inventory piece.' },
+ { label: 'Document', value: 'document', description: 'Envelopes, paperwork, and lightweight documents.' },
+ { label: 'Envelope', value: 'envelope', description: 'Flat document mailer or envelope.' },
+ { label: 'Box', value: 'box', description: 'Single boxed shipment unit.' },
+ { label: 'Carton', value: 'carton', description: 'Single carton, box, or case.' },
+ { label: 'Case', value: 'case', description: 'Case-packed goods or packaged case.' },
+ { label: 'Bundle', value: 'bundle', description: 'Bundled or strapped items shipped together.' },
+ { label: 'Bag', value: 'bag', description: 'Bagged goods, sacks, or pouches.' },
+ { label: 'Tote', value: 'tote', description: 'Reusable tote, bin, or container.' },
+ { label: 'Crate', value: 'crate', description: 'Rigid crate or caged shipment unit.' },
+ { label: 'Pallet', value: 'pallet', description: 'Palletized freight or grouped cargo.' },
+ { label: 'Container', value: 'container', description: 'Containerized shipment or storage container.' },
+ { label: 'Freight', value: 'freight', description: 'Loose freight or larger shipment unit.' },
+ { label: 'Cargo', value: 'cargo', description: 'General cargo not classified more specifically.' },
+ { label: 'Equipment', value: 'equipment', description: 'Tools, machines, or operational equipment.' },
+ { label: 'Machinery', value: 'machinery', description: 'Industrial machinery or large mechanical unit.' },
+ { label: 'Asset', value: 'asset', description: 'Tracked business asset or returnable item.' },
+ { label: 'Return', value: 'return', description: 'Returned goods or reverse-logistics item.' },
+ { label: 'Sample', value: 'sample', description: 'Sample item for trial, demo, or inspection.' },
+ { label: 'Spare Part', value: 'spare_part', description: 'Replacement component or service part.' },
+ { label: 'Medical Supply', value: 'medical_supply', description: 'Healthcare, lab, or medical delivery item.' },
+ { label: 'Food', value: 'food', description: 'Prepared food, meal, or grocery item.' },
+ { label: 'Beverage', value: 'beverage', description: 'Drink, liquid refreshment, or bottled shipment.' },
+ { label: 'Retail Goods', value: 'retail_goods', description: 'General consumer or retail merchandise.' },
+ { label: 'Passenger', value: 'passenger', description: 'Person transported as part of the order payload.' },
+];
+
export const contactStatuses = [
{ label: 'Active', value: 'active', description: 'Contact is valid and in use' },
{ label: 'Inactive', value: 'inactive', description: 'Contact is no longer valid' },
@@ -821,6 +853,7 @@ export default function fleetOpsOptions(key) {
fleetTypes,
fleetStatuses,
contactTypes,
+ entityTypes,
contactStatuses,
fuelReportTypes,
fuelReportStatuses,
diff --git a/addon/utils/serialize-payload.js b/addon/utils/serialize-payload.js
index 5a8a6a73..724f1968 100644
--- a/addon/utils/serialize-payload.js
+++ b/addon/utils/serialize-payload.js
@@ -5,7 +5,7 @@ export default function serializePayload(payload) {
const serialized = {
pickup: serializeModel(payload.pickup),
dropoff: serializeModel(payload.dropoff),
- entitities: serializeArray(payload.entities),
+ entities: serializeArray(payload.entities),
waypoints: serializeArray(payload.waypoints),
};
diff --git a/app/controllers/operations/orders/index/details/virtual.js b/app/controllers/operations/orders/index/details/virtual.js
new file mode 100644
index 00000000..6019798e
--- /dev/null
+++ b/app/controllers/operations/orders/index/details/virtual.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/operations/orders/index/details/virtual';
diff --git a/app/helpers/f-to-int.js b/app/helpers/f-to-int.js
new file mode 100644
index 00000000..0499e52a
--- /dev/null
+++ b/app/helpers/f-to-int.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/helpers/f-to-int';
diff --git a/composer.json b/composer.json
index 1a2bafbe..c069ce26 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/fleetops-api",
- "version": "0.6.44",
+ "version": "0.6.45",
"description": "Fleet & Transport Management Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
diff --git a/extension.json b/extension.json
index 7624b564..d14b5fa5 100644
--- a/extension.json
+++ b/extension.json
@@ -1,6 +1,6 @@
{
"name": "Fleet-Ops",
- "version": "0.6.44",
+ "version": "0.6.45",
"description": "Fleet & Transport Management Extension for Fleetbase",
"repository": "https://github.com/fleetbase/fleetops",
"license": "AGPL-3.0-or-later",
diff --git a/package.json b/package.json
index 6497f157..1b12ddfb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/fleetops-engine",
- "version": "0.6.44",
+ "version": "0.6.45",
"description": "Fleet & Transport Management Extension for Fleetbase",
"fleetbase": {
"route": "fleet-ops"
@@ -44,7 +44,7 @@
"@babel/core": "^7.23.2",
"@fleetbase/ember-core": "^0.3.18",
"@fleetbase/ember-ui": "^0.3.26",
- "@fleetbase/fleetops-data": "^0.1.29",
+ "@fleetbase/fleetops-data": "^0.1.30",
"@fleetbase/leaflet-routing-machine": "^3.2.17",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a2438081..3db1bbb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^0.3.26
version: 0.3.26(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(@glimmer/component@1.1.2(@babel/core@7.27.1))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(postcss@8.5.3)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.27.1))(webpack@5.99.8)
'@fleetbase/fleetops-data':
- specifier: ^0.1.29
- version: 0.1.29(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)
+ specifier: ^0.1.30
+ version: 0.1.30(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)
'@fleetbase/leaflet-routing-machine':
specifier: ^3.2.17
version: 3.2.17
@@ -1428,8 +1428,8 @@ packages:
resolution: {integrity: sha512-qE4AdrrFShEfoQjpItqHj3+OtQfs8pBH7rQPllX8R/MvwpDEOrEXiU7KG8IcQ1teW3YFsWc45FiidABKn+OFRw==}
engines: {node: '>= 18'}
- '@fleetbase/fleetops-data@0.1.29':
- resolution: {integrity: sha512-EoB5//1I3qbRNUs1zIc+dGfTGn7rVWaUJciuDbDssgg//V3LbV2ISDot4zicAhNZMTooA/qLr17jZKwNGl2+Tg==}
+ '@fleetbase/fleetops-data@0.1.30':
+ resolution: {integrity: sha512-n+jCGT2tFnhduHDEkBPENNn9UTaWNSygW9ZmNYiRs6fnGFaRsrMfvBrW+N9galB2dpvA7M8bJO+bl/exkb4dnA==}
engines: {node: '>= 18'}
'@fleetbase/intl-lint@0.0.1':
@@ -11042,7 +11042,7 @@ snapshots:
- webpack-command
- yaml
- '@fleetbase/fleetops-data@0.1.29(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)':
+ '@fleetbase/fleetops-data@0.1.30(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)':
dependencies:
'@babel/core': 7.27.1
'@fleetbase/ember-core': 0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php
index 4ea96be1..5ce7ddf9 100644
--- a/server/src/Http/Controllers/Api/v1/OrderController.php
+++ b/server/src/Http/Controllers/Api/v1/OrderController.php
@@ -321,7 +321,7 @@ public function create(CreateOrderRequest $request)
}
// load required relations
- $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
+ $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
// Determine if order should be dispatched on creation
$shouldDispatch = $request->boolean('dispatch') && $integratedVendorOrder === null;
@@ -364,7 +364,7 @@ public function update($id, UpdateOrderRequest $request)
// find for the order
try {
- $order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate', 'customer', 'facilitator']);
+ $order = Order::findRecordOrFail($id, ['trackingNumber', 'driverAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
} catch (ModelNotFoundException $exception) {
return response()->json(
[
@@ -549,7 +549,7 @@ public function update($id, UpdateOrderRequest $request)
$order->flushAttributesCache();
// load required relations
- $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
+ $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
// response the order resource
return new OrderResource($order);
@@ -759,7 +759,7 @@ public function find($id, Request $request)
{
// find for the order
try {
- $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
+ $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
} catch (ModelNotFoundException $exception) {
return response()->json(
[
@@ -839,7 +839,7 @@ public function getDistanceMatrix(string $id)
public function dispatchOrder(string $id)
{
try {
- $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate', 'customer', 'facilitator']);
+ $order = Order::findRecordOrFail($id, ['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']);
} catch (ModelNotFoundException $exception) {
return response()->json(
[
diff --git a/server/src/Http/Controllers/Api/v1/PurchaseRateController.php b/server/src/Http/Controllers/Api/v1/PurchaseRateController.php
index da67cd71..521580f7 100644
--- a/server/src/Http/Controllers/Api/v1/PurchaseRateController.php
+++ b/server/src/Http/Controllers/Api/v1/PurchaseRateController.php
@@ -41,10 +41,19 @@ public function create(CreatePurchaseRateRequest $request)
// order assignment
if ($request->has('order')) {
- $input['order_uuid'] = Utils::getUuid('orders', [
+ $orderUuid = Utils::getUuid('orders', [
'public_id' => $request->input('order'),
'company_uuid' => session('company'),
]);
+
+ $order = Order::where('uuid', $orderUuid)->first();
+
+ if ($order instanceof Order) {
+ $input['payload_uuid'] = $order->payload_uuid;
+ $input['customer_uuid'] = $order->customer_uuid;
+ $input['customer_type'] = $order->customer_type;
+ $input['company_uuid'] = $order->company_uuid ?? $input['company_uuid'];
+ }
} elseif ($createOrder) {
// create order from service quote
$serviceQuote = ServiceQuote::where('uuid', $input['service_quote_uuid'])->orWhere('public_id', $request->input('service_quote'))->first();
@@ -70,9 +79,8 @@ public function create(CreatePurchaseRateRequest $request)
// create the purchaseRate
$purchaseRate = PurchaseRate::create($input);
- // set purchase rate to order
if ($order instanceof Order) {
- $order->update(['purchase_rate_uuid' => $purchaseRate->uuid]);
+ $order->attachPurchaseRate($purchaseRate);
}
// response the driver resource
diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
index 6d26f8b0..81e74187 100644
--- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
@@ -98,6 +98,7 @@ function ($q) use ($currency) {
'amount' => $subTotal,
'currency' => $serviceRate->currency,
]);
+ $quote->setRelation('serviceRate', $serviceRate);
$items = $lines->map(function ($line) use ($quote) {
return ServiceQuoteItem::create([
@@ -143,6 +144,7 @@ function ($query) use ($request) {
'amount' => $subTotal,
'currency' => $serviceRate->currency,
]);
+ $quote->setRelation('serviceRate', $serviceRate);
$items = $lines->map(function ($line) use ($quote) {
return ServiceQuoteItem::create([
@@ -288,6 +290,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request)
'amount' => $subTotal,
'currency' => $serviceRate->currency,
]);
+ $quote->setRelation('serviceRate', $serviceRate);
// set preliminary data to meta
$quote->updateMeta('preliminary_data', $preliminaryData);
@@ -335,6 +338,7 @@ function ($query) use ($request) {
'amount' => $subTotal,
'currency' => $serviceRate->currency,
]);
+ $quote->setRelation('serviceRate', $serviceRate);
// set preliminary data to meta
$quote->updateMeta('preliminary_data', $preliminaryData);
diff --git a/server/src/Http/Controllers/Api/v1/ServiceRateController.php b/server/src/Http/Controllers/Api/v1/ServiceRateController.php
index 48aa0886..7e0fee4f 100644
--- a/server/src/Http/Controllers/Api/v1/ServiceRateController.php
+++ b/server/src/Http/Controllers/Api/v1/ServiceRateController.php
@@ -30,6 +30,8 @@ public function create(CreateServiceRateRequest $request)
'rate_calculation_method',
'currency',
'base_fee',
+ 'max_distance_unit',
+ 'max_distance',
'per_meter_unit',
'per_meter_flat_rate_fee',
'meter_fees',
@@ -119,7 +121,10 @@ public function update($id, UpdateServiceRateRequest $request)
'rate_calculation_method',
'currency',
'base_fee',
- 'per_km_flat_rate_fee',
+ 'max_distance_unit',
+ 'max_distance',
+ 'per_meter_unit',
+ 'per_meter_flat_rate_fee',
'meter_fees',
'meter_fees.*.distance',
'meter_fees.*.fee',
diff --git a/server/src/Http/Requests/CreateServiceRateRequest.php b/server/src/Http/Requests/CreateServiceRateRequest.php
index 9f5a28b0..2dd58cbb 100644
--- a/server/src/Http/Requests/CreateServiceRateRequest.php
+++ b/server/src/Http/Requests/CreateServiceRateRequest.php
@@ -31,10 +31,10 @@ public function rules()
'service_type' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
'service_area' => [Rule::exists('service_areas', 'public_id')->whereNull('deleted_at')],
'zone' => [Rule::exists('zones', 'public_id')->whereNull('deleted_at')],
- 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo'],
+ 'rate_calculation_method' => [Rule::requiredIf($this->isMethod('POST')), 'string', 'in:fixed_meter,fixed_rate,per_meter,per_drop,algo,parcel'],
'currency' => ['required', 'size:3'],
'base_fee' => ['numeric'],
- 'per_meter_unit' => ['required_if:rate_calculation_method,per_meter', 'string', 'in:km,m'],
+ 'per_meter_unit' => ['required_if:rate_calculation_method,per_meter', 'string', 'in:km,m,ft,yd,mi'],
'per_meter_flat_rate_fee' => ['required_if:rate_calculation_method,per_meter', 'numeric'],
'meter_fees' => [Rule::requiredIf(function () {
return in_array($this->input('rate_calculation_method'), ['fixed_meter', 'fixed_rate']);
diff --git a/server/src/Http/Resources/v1/Order.php b/server/src/Http/Resources/v1/Order.php
index 7985e60e..853db2e7 100644
--- a/server/src/Http/Resources/v1/Order.php
+++ b/server/src/Http/Resources/v1/Order.php
@@ -45,7 +45,6 @@ public function toArray($request): array
'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid),
'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
'vehicle_assigned_uuid'=> $this->when($isInternal, $this->vehicle_assigned_uuid),
- 'service_quote_uuid' => $this->when($isInternal, $this->service_quote_uuid),
'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned),
'is_scheduled' => $this->when($isInternal, $this->is_scheduled),
'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
diff --git a/server/src/Http/Resources/v1/ServiceQuote.php b/server/src/Http/Resources/v1/ServiceQuote.php
index 5de30370..2376cb7d 100644
--- a/server/src/Http/Resources/v1/ServiceQuote.php
+++ b/server/src/Http/Resources/v1/ServiceQuote.php
@@ -23,8 +23,8 @@ public function toArray($request)
'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
'service_rate_uuid' => $this->when(Http::isInternalRequest(), $this->service_rate_uuid),
'payload_uuid' => $this->when(Http::isInternalRequest(), $this->payload_uuid),
- 'service_rate_name' => data_get($this, 'serviceRate.name'),
- 'service_name' => data_get($this, 'serviceRate.name'),
+ 'service_rate_name' => data_get($this, 'serviceRate.service_name'),
+ 'service_name' => data_get($this, 'serviceRate.service_name'),
'service_rate' => $this->when(Http::isPublicRequest(), data_get($this, 'serviceRate.public_id')),
'facilitator' => $this->when(Http::isPublicRequest(), data_get($this, 'integratedVendor.public_id')),
'items' => ServiceQuoteItem::collection($this->items),
diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php
index 6195b5f6..2aa02370 100644
--- a/server/src/Models/Entity.php
+++ b/server/src/Models/Entity.php
@@ -168,6 +168,13 @@ public function label()
])->render();
}
+ public function setTypeAttribute(?string $type = null): void
+ {
+ $type = is_string($type) ? trim($type) : $type;
+
+ $this->attributes['type'] = filled($type) ? Str::snake($type) : null;
+ }
+
/**
* @var BelongsTo
*/
diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php
index 1fc75503..07b59950 100644
--- a/server/src/Models/Order.php
+++ b/server/src/Models/Order.php
@@ -1055,9 +1055,7 @@ public function purchaseQuote(string $serviceQuoteId, $meta = [])
'meta' => $meta,
]);
- $this->purchase_rate_uuid = $purchasedRate->uuid;
-
- return $this->save();
+ return $this->attachPurchaseRate($purchasedRate);
}
/**
@@ -1096,14 +1094,48 @@ public function purchaseServiceQuote($serviceQuote, $meta = [])
'meta' => $meta,
]);
- return $this->updateQuietly([
- 'purchase_rate_uuid' => $purchasedRate->uuid,
- ]);
+ return $this->attachPurchaseRate($purchasedRate);
}
return false;
}
+ /**
+ * Attach a purchase rate to the order and void any superseded transaction.
+ */
+ public function attachPurchaseRate(PurchaseRate $purchaseRate): bool
+ {
+ $oldTransaction = $this->transaction_uuid
+ ? Transaction::where('uuid', $this->transaction_uuid)->first()
+ : null;
+
+ $attached = (bool) $this->updateQuietly([
+ 'purchase_rate_uuid' => $purchaseRate->uuid,
+ 'transaction_uuid' => $purchaseRate->transaction_uuid,
+ ]);
+
+ if ($attached) {
+ $this->setRelation('purchaseRate', $purchaseRate);
+
+ if ($purchaseRate->transaction_uuid) {
+ $this->setRelation('transaction', Transaction::where('uuid', $purchaseRate->transaction_uuid)->first());
+ }
+
+ if (
+ $oldTransaction instanceof Transaction
+ && $oldTransaction->uuid !== $purchaseRate->transaction_uuid
+ && !$oldTransaction->trashed()
+ ) {
+ $oldTransaction->update([
+ 'status' => Transaction::STATUS_VOIDED,
+ 'voided_at' => now(),
+ ]);
+ }
+ }
+
+ return $attached;
+ }
+
/**
* Creates a transaction for the order without a service quote.
* This method is used when an order is made without selecting a specific service quote.
@@ -1122,7 +1154,7 @@ public function createOrderTransactionWithoutServiceQuote(): ?Transaction
'gateway_transaction_id' => Transaction::generateNumber(),
'gateway' => 'internal',
'amount' => 0,
- 'currency' => data_get($this->company, 'country') ? Utils::getCurrenyFromCountryCode(data_get($this->company, 'country')) : 'SGD',
+ 'currency' => Utils::getCompanyTransactionCurrency($this->company ?? $this->company_uuid),
'description' => 'Dispatch order',
'type' => 'dispatch',
'status' => 'success',
diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php
index 4e7053d8..ea9dcc27 100644
--- a/server/src/Models/ServiceRate.php
+++ b/server/src/Models/ServiceRate.php
@@ -369,11 +369,37 @@ public function setServiceRateParcelFees(?array $serviceRateParcelFees = [])
return $this;
}
+ // Normalize duplicate parcel rows by fee shape and keep the latest submitted value.
+ $serviceRateParcelFees = collect($serviceRateParcelFees)
+ ->filter(fn ($fee) => is_array($fee))
+ ->map(function ($fee) {
+ return collect($fee)->except(['created_at', 'updated_at'])->toArray();
+ })
+ ->keyBy(function ($fee) {
+ return implode(':', [
+ data_get($fee, 'size'),
+ data_get($fee, 'length'),
+ data_get($fee, 'width'),
+ data_get($fee, 'height'),
+ data_get($fee, 'dimensions_unit'),
+ data_get($fee, 'weight'),
+ data_get($fee, 'weight_unit'),
+ ]);
+ })
+ ->values()
+ ->toArray();
+
+ $submittedUuids = collect($serviceRateParcelFees)
+ ->pluck('uuid')
+ ->filter()
+ ->values()
+ ->all();
+
$iterate = count($serviceRateParcelFees);
for ($i = 0; $i < $iterate; $i++) {
// if already has uuid then we just update the record and remove from insert array
- if (isset($serviceRateParcelFees[$i]['uuid'])) {
+ if (!empty($serviceRateParcelFees[$i]['uuid'])) {
$id = $serviceRateParcelFees[$i]['uuid'];
$updateableAttributes = collect($serviceRateParcelFees[$i])->except(['uuid', 'created_at', 'updated_at'])->toArray();
@@ -391,7 +417,18 @@ public function setServiceRateParcelFees(?array $serviceRateParcelFees = [])
}
$serviceRateParcelFees = collect($serviceRateParcelFees)->filter()->values()->toArray();
- ServiceRateParcelFee::bulkInsert($serviceRateParcelFees);
+
+ $existingParcelFeesQuery = ServiceRateParcelFee::where('service_rate_uuid', $this->uuid);
+
+ if (!empty($submittedUuids)) {
+ $existingParcelFeesQuery->whereNotIn('uuid', $submittedUuids)->delete();
+ } else {
+ $existingParcelFeesQuery->delete();
+ }
+
+ if (!empty($serviceRateParcelFees)) {
+ ServiceRateParcelFee::bulkInsert($serviceRateParcelFees);
+ }
return $this;
}
@@ -688,13 +725,13 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
}
if ($this->isPerMeter()) {
- $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance;
- $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee;
+ $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit);
+ $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee);
$subTotal += $rateFee;
$lines->push([
'details' => 'Service Fee',
- 'amount' => Utils::numbersOnly($rateFee),
+ 'amount' => $rateFee,
'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
'currency' => $this->currency,
'code' => 'BASE_FEE',
@@ -702,20 +739,23 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
}
if ($this->isAlgorithm()) {
- $rateFee = Algo::exec(
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
$this->algorithm,
- [
- 'distance' => $totalDistance,
- 'time' => $totalTime,
- ],
+ $this->buildAlgorithmVariables(
+ $entities,
+ $waypoints,
+ $totalDistance,
+ $totalTime,
+ $this->inferEndpointCountFromStops($waypoints)
+ ),
true
- );
+ ));
- $subTotal += Utils::numbersOnly($rateFee);
+ $subTotal += $rateFee;
$lines->push([
'details' => 'Service Fee',
- 'amount' => Utils::numbersOnly($rateFee),
+ 'amount' => $rateFee,
'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
'currency' => $this->currency,
'code' => 'BASE_FEE',
@@ -758,9 +798,10 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
}
$subTotal += $serviceParcelFee->fee;
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
$lines->push([
- 'details' => $serviceParcelFee->name . ' parcel fee',
+ 'details' => $parcelFeeName . ' parcel fee',
'amount' => Utils::numbersOnly($serviceParcelFee->fee),
'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
'currency' => $this->currency,
@@ -777,7 +818,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
if ($this->hasCodFlatFee()) {
$subTotal += $codFee = $this->cod_flat_fee;
} elseif ($this->hasCodPercentageFee()) {
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
}
$lines->push([
@@ -794,7 +835,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
if ($this->hasPeakHoursFlatFee()) {
$subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
} elseif ($this->hasPeakHoursPercentageFee()) {
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
}
$lines->push([
@@ -872,13 +913,13 @@ public function quote(Payload $payload)
}
if ($this->isPerMeter()) {
- $perMeterDistance = $this->per_meter_unit === 'km' ? round($totalDistance / 1000) : $totalDistance;
- $rateFee = $perMeterDistance * $this->per_meter_flat_rate_fee;
+ $perMeterDistance = $this->normalizeDistanceForUnit($totalDistance, $this->per_meter_unit);
+ $rateFee = $this->normalizeCalculatedMoney($perMeterDistance * $this->per_meter_flat_rate_fee);
$subTotal += $rateFee;
$lines->push([
'details' => 'Service Fee',
- 'amount' => Utils::numbersOnly($rateFee),
+ 'amount' => $rateFee,
'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
'currency' => $this->currency,
'code' => 'BASE_FEE',
@@ -886,20 +927,23 @@ public function quote(Payload $payload)
}
if ($this->isAlgorithm()) {
- $rateFee = Algo::exec(
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
$this->algorithm,
- [
- 'distance' => $totalDistance,
- 'time' => $totalTime,
- ],
+ $this->buildAlgorithmVariables(
+ $payload->entities->all(),
+ $waypoints->all(),
+ $totalDistance,
+ $totalTime,
+ (int) ($payload->pickup ? 1 : 0) + (int) ($payload->dropoff ? 1 : 0)
+ ),
true
- );
+ ));
- $subTotal += Utils::numbersOnly($rateFee);
+ $subTotal += $rateFee;
$lines->push([
'details' => 'Service Fee',
- 'amount' => Utils::numbersOnly($rateFee),
+ 'amount' => $rateFee,
'formatted_amount' => Utils::moneyFormat($rateFee, $this->currency),
'currency' => $this->currency,
'code' => 'BASE_FEE',
@@ -942,9 +986,10 @@ public function quote(Payload $payload)
}
$subTotal += $serviceParcelFee->fee;
+ $parcelFeeName = ucwords(str_replace(['_', '-'], ' ', data_get($serviceParcelFee, 'size', 'parcel')));
$lines->push([
- 'details' => $serviceParcelFee->name . ' parcel fee',
+ 'details' => $parcelFeeName . ' parcel fee',
'amount' => Utils::numbersOnly($serviceParcelFee->fee),
'formatted_amount' => Utils::moneyFormat($serviceParcelFee->fee, $this->currency),
'currency' => $this->currency,
@@ -961,7 +1006,7 @@ public function quote(Payload $payload)
if ($this->hasCodFlatFee()) {
$subTotal += $codFee = $this->cod_flat_fee;
} elseif ($this->hasCodPercentageFee()) {
- $subTotal += $codFee = Utils::calculatePercentage($this->cod_percent, $baseRate);
+ $subTotal += $codFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->cod_percent, $baseRate));
}
$lines->push([
@@ -978,7 +1023,7 @@ public function quote(Payload $payload)
if ($this->hasPeakHoursFlatFee()) {
$subTotal += $peakHoursFee = $this->peak_hours_flat_fee;
} elseif ($this->hasPeakHoursPercentageFee()) {
- $subTotal += $peakHoursFee = Utils::calculatePercentage($this->peak_hours_percent, $baseRate);
+ $subTotal += $peakHoursFee = $this->normalizeCalculatedMoney(Utils::calculatePercentage($this->peak_hours_percent, $baseRate));
}
$lines->push([
@@ -993,6 +1038,53 @@ public function quote(Payload $payload)
return [$subTotal, $lines];
}
+ protected function normalizeDistanceForUnit(?int $distanceInMeters = 0, ?string $unit = 'm'): float|int
+ {
+ $distanceInMeters = (float) ($distanceInMeters ?? 0);
+
+ return match ($unit) {
+ 'km' => $distanceInMeters / 1000,
+ 'ft' => $distanceInMeters / 0.3048,
+ 'yd' => $distanceInMeters / 0.9144,
+ 'mi' => $distanceInMeters / 1609.344,
+ default => $distanceInMeters,
+ };
+ }
+
+ protected function normalizeCalculatedMoney($amount = 0): int
+ {
+ return (int) round((float) ($amount ?? 0));
+ }
+
+ protected function buildAlgorithmVariables($entities = [], $stops = [], ?int $totalDistance = 0, ?int $totalTime = 0, int $endpointCount = 0): array
+ {
+ $entityCollection = collect($entities)->filter();
+ $stopCount = collect($stops)->filter()->count();
+ $endpointCount = max(0, min($endpointCount, $stopCount));
+ $waypointCount = max($stopCount - $endpointCount, 0);
+
+ return Algo::normalizeVariables([
+ 'distance_m' => $totalDistance ?? 0,
+ 'time_s' => $totalTime ?? 0,
+ 'stops' => $stopCount,
+ 'waypoints' => $waypointCount,
+ 'parcels' => $entityCollection->where('type', 'parcel')->count(),
+ 'entities' => $entityCollection->count(),
+ 'base_fee' => Utils::numbersOnly($this->base_fee ?? 0),
+ ]);
+ }
+
+ protected function inferEndpointCountFromStops($stops = []): int
+ {
+ $stopCount = collect($stops)->filter()->count();
+
+ if ($stopCount <= 1) {
+ return $stopCount;
+ }
+
+ return 2;
+ }
+
/**
* Find the ServiceRateFee based on the total distance.
*
diff --git a/server/src/Observers/PurchaseRateObserver.php b/server/src/Observers/PurchaseRateObserver.php
index d89526a7..d1a3e3dc 100644
--- a/server/src/Observers/PurchaseRateObserver.php
+++ b/server/src/Observers/PurchaseRateObserver.php
@@ -2,7 +2,6 @@
namespace Fleetbase\FleetOps\Observers;
-use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Models\PurchaseRate;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Models\Company;
@@ -25,7 +24,8 @@ public function creating(PurchaseRate $purchaseRate)
$company = Company::where('uuid', session('company', $purchaseRate->company_uuid))->first();
// get currency to use
- $currency = data_get($purchaseRate, 'serviceQuote.currency', $company->country ? Utils::getCurrenyFromCountryCode($company->country) : 'SGD');
+ $currency = data_get($purchaseRate, 'serviceQuote.currency')
+ ?: Utils::getCompanyTransactionCurrency($company ?? $purchaseRate->company_uuid);
// create transaction and transaction items
$transaction = Transaction::create([
@@ -41,9 +41,6 @@ public function creating(PurchaseRate $purchaseRate)
'status' => 'success',
]);
- // Update order with transaction id
- Order::where('payload_uuid', $purchaseRate->payload_uuid)->update(['transaction_uuid' => $transaction->uuid]);
-
if (isset($purchaseRate->serviceQuote)) {
$purchaseRate->serviceQuote->items->each(function ($serviceQuoteItem) use ($transaction, $currency) {
TransactionItem::create([
@@ -57,5 +54,6 @@ public function creating(PurchaseRate $purchaseRate)
}
$purchaseRate->transaction_uuid = $transaction->uuid;
+ $purchaseRate->status = $purchaseRate->status ?: Transaction::STATUS_SUCCESS;
}
}
diff --git a/server/src/Rules/ComputableAlgo.php b/server/src/Rules/ComputableAlgo.php
index 25e8207c..d743bed8 100644
--- a/server/src/Rules/ComputableAlgo.php
+++ b/server/src/Rules/ComputableAlgo.php
@@ -16,9 +16,7 @@ class ComputableAlgo implements Rule
*/
public function passes($attribute, $value)
{
- $distanceAndTime = Algo::calculateDrivingDistanceAndTime('1.3506853', '103.87199110000006', '1.3621663', '103.88450490000002');
-
- return Algo::exec($value, $distanceAndTime) > 0;
+ return Algo::isComputable($value);
}
/**
diff --git a/server/src/Support/Algo.php b/server/src/Support/Algo.php
index 83c91d83..62554153 100644
--- a/server/src/Support/Algo.php
+++ b/server/src/Support/Algo.php
@@ -6,6 +6,8 @@
class Algo
{
+ protected const FUNCTION_PATTERN = '/\b(max|min|ceil|floor|round)\(([^()]*)\)/';
+
/**
* Execute an algorithm strig.
*
@@ -16,11 +18,18 @@ public static function exec($algorithm, $variables = [], $round = false)
$m = new EvalMath();
$m->suppress_errors = true;
+ $variables = static::normalizeVariables($variables);
+
foreach ($variables as $key => $value) {
$algorithm = str_replace('{' . $key . '}', $value, $algorithm);
}
- $result = $m->evaluate($algorithm);
+ $algorithm = static::evaluateFunctions($algorithm);
+ $result = $m->evaluate($algorithm);
+
+ if ($result === false || $result === null || !is_numeric($result)) {
+ return null;
+ }
if ($round) {
return round($result, 2); // precision 2 cuz most likely dealing with $
@@ -28,4 +37,81 @@ public static function exec($algorithm, $variables = [], $round = false)
return $result;
}
+
+ public static function isComputable($algorithm): bool
+ {
+ $result = static::exec($algorithm, static::validationFixture(), true);
+
+ return $result !== null && is_numeric($result);
+ }
+
+ public static function normalizeVariables($variables = []): array
+ {
+ $variables = collect($variables)->all();
+
+ $distanceM = (float) ($variables['distance_m'] ?? $variables['distance'] ?? 0);
+ $timeS = (float) ($variables['time_s'] ?? $variables['time'] ?? 0);
+
+ $normalized = [
+ 'distance_m' => $distanceM,
+ 'distance' => $distanceM,
+ 'distance_km'=> $distanceM / 1000,
+ 'distance_mi'=> $distanceM / 1609.344,
+ 'time_s' => $timeS,
+ 'time' => $timeS,
+ 'time_min' => $timeS / 60,
+ 'stops' => (int) ($variables['stops'] ?? 0),
+ 'waypoints' => (int) ($variables['waypoints'] ?? 0),
+ 'parcels' => (int) ($variables['parcels'] ?? 0),
+ 'entities' => (int) ($variables['entities'] ?? 0),
+ 'base_fee' => (float) ($variables['base_fee'] ?? 0),
+ ];
+
+ return array_merge($normalized, $variables);
+ }
+
+ protected static function validationFixture(): array
+ {
+ return static::normalizeVariables([
+ 'distance_m' => 25000,
+ 'time_s' => 5400,
+ 'stops' => 4,
+ 'waypoints' => 2,
+ 'parcels' => 3,
+ 'entities' => 5,
+ 'base_fee' => 100,
+ ]);
+ }
+
+ protected static function evaluateFunctions(string $algorithm): string
+ {
+ while (preg_match(static::FUNCTION_PATTERN, $algorithm)) {
+ $algorithm = preg_replace_callback(static::FUNCTION_PATTERN, function ($matches) {
+ $function = $matches[1];
+ $args = array_map('trim', explode(',', $matches[2]));
+ $m = new EvalMath();
+ $m->suppress_errors = true;
+ $numbers = array_map(function ($arg) use ($m) {
+ $result = $m->evaluate($arg);
+
+ if ($result === false || $result === null || !is_numeric($result)) {
+ return (float) $arg;
+ }
+
+ return (float) $result;
+ }, $args);
+
+ return match ($function) {
+ 'max' => (string) max($numbers[0] ?? 0, $numbers[1] ?? 0),
+ 'min' => (string) min($numbers[0] ?? 0, $numbers[1] ?? 0),
+ 'ceil' => (string) ceil($numbers[0] ?? 0),
+ 'floor' => (string) floor($numbers[0] ?? 0),
+ 'round' => (string) round($numbers[0] ?? 0, (int) ($numbers[1] ?? 0)),
+ default => $matches[0],
+ };
+ }, $algorithm);
+ }
+
+ return $algorithm;
+ }
}
diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php
index 7201c2ce..c509a93c 100644
--- a/server/src/Support/Utils.php
+++ b/server/src/Support/Utils.php
@@ -3,6 +3,8 @@
namespace Fleetbase\FleetOps\Support;
use Fleetbase\FleetOps\Flow\Activity;
+use Fleetbase\Models\Company;
+use Fleetbase\Models\Setting;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
use Fleetbase\Support\Utils as FleetbaseUtils;
use Illuminate\Support\Arr;
@@ -20,6 +22,39 @@ class Utils extends FleetbaseUtils
*/
public const DRIVING_TIME_MULTIPLIER = 7.2;
+ /**
+ * Resolve the preferred transaction currency for a company.
+ *
+ * Resolution order:
+ * 1. Organization/company configured currency
+ * 2. Ledger accounting base currency
+ * 3. USD
+ */
+ public static function getCompanyTransactionCurrency(string|Company|null $company = null): string
+ {
+ $companyModel = $company instanceof Company
+ ? $company
+ : (filled($company) ? Company::where('uuid', $company)->first() : null);
+
+ $companyUuid = $companyModel?->uuid ?? (is_string($company) ? $company : null);
+
+ $companyCurrency = data_get($companyModel, 'currency');
+ if (filled($companyCurrency)) {
+ return strtoupper($companyCurrency);
+ }
+
+ if (filled($companyUuid)) {
+ $accountingSettings = Setting::lookup('company.' . $companyUuid . '.ledger.accounting-settings', []);
+ $baseCurrency = data_get($accountingSettings, 'base_currency');
+
+ if (filled($baseCurrency)) {
+ return strtoupper($baseCurrency);
+ }
+ }
+
+ return 'USD';
+ }
+
/**
* Get a formatted string representation of a place's address.
*
diff --git a/tests/integration/helpers/f-to-int-test.js b/tests/integration/helpers/f-to-int-test.js
new file mode 100644
index 00000000..894d8213
--- /dev/null
+++ b/tests/integration/helpers/f-to-int-test.js
@@ -0,0 +1,17 @@
+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 | Helper | f-to-int', function (hooks) {
+ setupRenderingTest(hooks);
+
+ // TODO: Replace this with your real tests.
+ test('it renders', async function (assert) {
+ this.set('inputValue', '1234');
+
+ await render(hbs`{{f-to-int this.inputValue}}`);
+
+ assert.dom().hasText('1234');
+ });
+});
diff --git a/tests/unit/controllers/operations/orders/index/details/virtual-test.js b/tests/unit/controllers/operations/orders/index/details/virtual-test.js
new file mode 100644
index 00000000..3894d139
--- /dev/null
+++ b/tests/unit/controllers/operations/orders/index/details/virtual-test.js
@@ -0,0 +1,12 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+
+module('Unit | Controller | operations/orders/index/details/virtual', function (hooks) {
+ setupTest(hooks);
+
+ // TODO: Replace this with your real tests.
+ test('it exists', function (assert) {
+ let controller = this.owner.lookup('controller:operations/orders/index/details/virtual');
+ assert.ok(controller);
+ });
+});
diff --git a/tests/unit/routes/operations/orders/index/details-test.js b/tests/unit/routes/operations/orders/index/details-test.js
index 326f405e..748af817 100644
--- a/tests/unit/routes/operations/orders/index/details-test.js
+++ b/tests/unit/routes/operations/orders/index/details-test.js
@@ -8,4 +8,82 @@ module('Unit | Route | operations/orders/index/details', function (hooks) {
let route = this.owner.lookup('route:operations/orders/index/details');
assert.ok(route);
});
+
+ test('willTransition does not cleanup when switching inside order details tabs', function (assert) {
+ const route = this.owner.lookup('route:operations/orders/index/details');
+
+ let stopCalled = false;
+ let removeCalled = false;
+ let showCalled = false;
+
+ route.orderSocketEvents = {
+ stop() {
+ stopCalled = true;
+ },
+ };
+ route.leafletMapManager = {
+ removeRoutingControl() {
+ removeCalled = true;
+ },
+ };
+ route.universe = {
+ sidebarContext: {
+ show() {
+ showCalled = true;
+ },
+ },
+ };
+ route.controllerFor = () => ({
+ model: { id: 'order_1' },
+ routingControl: { id: 'rc_1' },
+ });
+
+ route.willTransition({
+ from: { name: 'console.fleet-ops.operations.orders.index.details.virtual' },
+ to: { name: 'console.fleet-ops.operations.orders.index.details.index' },
+ });
+
+ assert.false(stopCalled);
+ assert.false(removeCalled);
+ assert.false(showCalled);
+ });
+
+ test('willTransition cleans up when leaving the order details route tree', function (assert) {
+ const route = this.owner.lookup('route:operations/orders/index/details');
+
+ let stopCalled = false;
+ let removeCalled = false;
+ let showCalled = false;
+
+ route.orderSocketEvents = {
+ stop() {
+ stopCalled = true;
+ },
+ };
+ route.leafletMapManager = {
+ removeRoutingControl() {
+ removeCalled = true;
+ },
+ };
+ route.universe = {
+ sidebarContext: {
+ show() {
+ showCalled = true;
+ },
+ },
+ };
+ route.controllerFor = () => ({
+ model: { id: 'order_1' },
+ routingControl: { id: 'rc_1' },
+ });
+
+ route.willTransition({
+ from: { name: 'console.fleet-ops.operations.orders.index.details.index' },
+ to: { name: 'console.fleet-ops.operations.orders.index' },
+ });
+
+ assert.true(stopCalled);
+ assert.true(removeCalled);
+ assert.true(showCalled);
+ });
});
diff --git a/tests/unit/utils/serialize-payload-test.js b/tests/unit/utils/serialize-payload-test.js
index 6ac106db..ac074de7 100644
--- a/tests/unit/utils/serialize-payload-test.js
+++ b/tests/unit/utils/serialize-payload-test.js
@@ -2,9 +2,15 @@ import serializePayload from 'dummy/utils/serialize-payload';
import { module, test } from 'qunit';
module('Unit | Utility | serialize-payload', function () {
- // TODO: Replace this with your real tests.
- test('it works', function (assert) {
- let result = serializePayload();
- assert.ok(result);
+ test('it serializes entities under the correct key', function (assert) {
+ let result = serializePayload({
+ pickup: { id: 'pickup-1' },
+ dropoff: { id: 'dropoff-1' },
+ entities: [{ id: 'entity-1' }],
+ waypoints: [{ id: 'waypoint-1' }],
+ });
+
+ assert.true(Object.prototype.hasOwnProperty.call(result, 'entities'));
+ assert.false(Object.prototype.hasOwnProperty.call(result, 'entitities'));
});
});