diff --git a/addon/models/driver.js b/addon/models/driver.js index b1f3e6f..26876f8 100644 --- a/addon/models/driver.js +++ b/addon/models/driver.js @@ -60,7 +60,7 @@ export default class DriverModel extends Model { @attr('number') heading; @attr('string') country; @attr('string') city; - @attr('string') status; + @attr('string', { defaultValue: 'available' }) status; @attr('boolean') online; @attr('raw') meta; diff --git a/addon/models/service-rate-fee.js b/addon/models/service-rate-fee.js index 1cce7c6..6ce8d89 100644 --- a/addon/models/service-rate-fee.js +++ b/addon/models/service-rate-fee.js @@ -1,4 +1,4 @@ -import Model, { attr } from '@ember-data/model'; +import Model, { attr, belongsTo } from '@ember-data/model'; import { computed } from '@ember/object'; import { format as formatDate, isValid as isValidDate, formatDistanceToNow } from 'date-fns'; @@ -6,8 +6,17 @@ export default class ServiceRateFeeModel extends Model { /** @ids */ @attr('string') uuid; @attr('string') service_rate_uuid; + @attr('string') service_area_uuid; + @attr('string') zone_uuid; + + /** @relationships */ + @belongsTo('service-area') service_area; + @belongsTo('zone') zone; /** @attributes */ + @attr('string') label; + @attr('number') priority; + @attr('boolean', { defaultValue: false }) is_fallback; @attr('number') distance; @attr('string') distance_unit; @attr('string') unit; @@ -64,11 +73,28 @@ export default class ServiceRateFeeModel extends Model { return formatDate(this.created_at, 'dd, MMM'); } + @computed('is_fallback', 'zone_uuid', 'service_area_uuid', 'zone.id', 'service_area.id') get geography_type() { + if (this.is_fallback) { + return 'fallback'; + } + + if (this.zone_uuid || this.zone?.id) { + return 'zone'; + } + + return 'service_area'; + } + /** @methods */ toJSON() { return { uuid: this.uuid, service_rate_uuid: this.service_rate_uuid, + service_area_uuid: this.service_area_uuid, + zone_uuid: this.zone_uuid, + label: this.label, + priority: this.priority, + is_fallback: this.is_fallback, distance: this.distance, distance_unit: this.distance_unit, min: this.min, diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 80e1e24..915dbf6 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -88,6 +88,10 @@ export default class ServiceRate extends Model { return this.rate_calculation_method === 'per_meter'; } + @computed('rate_calculation_method') get isMultiZoneDistance() { + return this.rate_calculation_method === 'multi_zone_distance'; + } + @computed('rate_calculation_method') get isPerDrop() { return this.rate_calculation_method === 'per_drop'; } @@ -116,9 +120,59 @@ export default class ServiceRate extends Model { return this.cod_calculation_method === 'percentage'; } - @computed('rate_fees.@each.{distance,min,max,unit}', 'max_distance', 'rate_calculation_method', 'isPerDrop') get rateFees() { + @computed( + 'rate_fees.@each.{id,uuid,distance,min,max,unit,fee,label,priority,is_fallback,zone_uuid,service_area_uuid,updated_at}', + 'max_distance', + 'rate_calculation_method', + 'isPerDrop', + 'isMultiZoneDistance' + ) + get rateFees() { const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => !r.isDeleted); + if (this.isMultiZoneDistance) { + const deduped = new Map(); + const rankFee = (fee) => { + if (fee.id && !fee.isNew) { + return 3; + } + + if (!fee.isNew) { + return 2; + } + + return 1; + }; + const updatedAtMs = (fee) => { + const value = fee.updated_at instanceof Date ? fee.updated_at : new Date(fee.updated_at); + const timestamp = value.getTime(); + + return Number.isNaN(timestamp) ? 0 : timestamp; + }; + const geographyId = (fee) => { + if (fee.is_fallback) { + return 'fallback'; + } + + return fee.zone_uuid || fee.zone?.id || fee.service_area_uuid || fee.service_area?.id || 'unassigned'; + }; + + existing + .filter((r) => r.unit === 'multi_zone_distance') + .forEach((fee) => { + const key = `multi-zone:${fee.is_fallback}:${geographyId(fee)}:${fee.priority}:${fee.label}`; + const current = deduped.get(key); + const feeRank = rankFee(fee); + const currentRank = current ? rankFee(current) : 0; + + if (!current || feeRank > currentRank || (feeRank === currentRank && updatedAtMs(fee) >= updatedAtMs(current))) { + deduped.set(key, fee); + } + }); + + return Array.from(deduped.values()).sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); + } + if (this.isPerDrop) { const deduped = new Map(); const rankFee = (fee) => { @@ -214,6 +268,8 @@ export default class ServiceRate extends Model { }); this.rate_fees.addObject(newFee); + + return newFee; } @action removePerDropFee(fee) { @@ -236,4 +292,43 @@ export default class ServiceRate extends Model { const defaultFee = this.createDefaultPerDropFee(); this.rate_fees.addObject(defaultFee); } + + @action addMultiZoneDistanceRule(attributes = {}) { + const store = getOwner(this).lookup('service:store'); + const existingFees = this.rate_fees?.toArray?.() ?? []; + const nextPriority = existingFees.filter((fee) => fee.unit === 'multi_zone_distance').reduce((highest, fee) => Math.max(highest, Number(fee.priority) || 0), 0) + 10; + + const newFee = store.createRecord('service-rate-fee', { + label: 'Distance rule', + priority: nextPriority, + is_fallback: false, + distance_unit: 'km', + unit: 'multi_zone_distance', + fee: 0, + currency: this.currency, + ...attributes, + }); + + this.rate_fees.addObject(newFee); + } + + @action addMultiZoneDistanceFallbackRule() { + const existingFallback = (this.rate_fees?.toArray?.() ?? []).find((fee) => fee.unit === 'multi_zone_distance' && fee.is_fallback && !fee.isDeleted); + + if (existingFallback) { + return existingFallback; + } + + return this.addMultiZoneDistanceRule({ + label: 'Fallback distance', + priority: 0, + is_fallback: true, + }); + } + + @action removeMultiZoneDistanceRule(fee) { + if (!fee || !fee.destroyRecord) return; + this.rate_fees.removeObject(fee); + fee.destroyRecord(); + } } diff --git a/addon/models/vehicle.js b/addon/models/vehicle.js index 5ea3860..7874f2c 100644 --- a/addon/models/vehicle.js +++ b/addon/models/vehicle.js @@ -144,7 +144,7 @@ export default class VehicleModel extends Model { /** Misc text / meta */ @attr('string') notes; - @attr('string') status; + @attr('string', { defaultValue: 'available' }) status; @attr('string') slug; @attr('boolean') online; @attr('raw') vin_data; diff --git a/addon/serializers/service-rate-fee.js b/addon/serializers/service-rate-fee.js index 117b548..49913c4 100644 --- a/addon/serializers/service-rate-fee.js +++ b/addon/serializers/service-rate-fee.js @@ -1,4 +1,11 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; -export default class ServiceRateFeeSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {} +export default class ServiceRateFeeSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + service_area: { embedded: 'always', serialize: false }, + zone: { embedded: 'always', serialize: false }, + }; + } +} diff --git a/addon/serializers/service-rate.js b/addon/serializers/service-rate.js index 9e106bc..310e11c 100644 --- a/addon/serializers/service-rate.js +++ b/addon/serializers/service-rate.js @@ -48,14 +48,19 @@ export default class ServiceRateSerializer extends ApplicationSerializer.extend( return `drop:${fee.min}:${fee.max}:${fee.unit}`; } + if (fee.unit === 'multi_zone_distance') { + return `multi-zone:${fee.service_area_uuid}:${fee.zone_uuid}:${fee.is_fallback}:${fee.priority}:${fee.label}`; + } + return `distance:${fee.distance}`; }; const savedByKey = new Map(savedRateFees.map((f) => [savedFeeKey(f), f])); + const hasSavedMultiZoneFees = savedRateFees.some((fee) => fee.unit === 'multi_zone_distance'); // Only remove unsaved fees that duplicate saved fees unsavedRateFees.forEach((fee) => { - if (savedByKey.has(savedFeeKey(fee))) { + if ((hasSavedMultiZoneFees && fee.unit === 'multi_zone_distance') || savedByKey.has(savedFeeKey(fee))) { serviceRate.get('rate_fees').removeObject(fee); fee.unloadRecord(); } diff --git a/package.json b/package.json index 5115e13..b7d7837 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-data", - "version": "0.1.33", + "version": "0.1.34", "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters and GeoJson utility functions.", "keywords": [ "fleetbase-data", @@ -37,7 +37,7 @@ }, "dependencies": { "@babel/core": "^7.23.2", - "@fleetbase/ember-core": "^0.3.18", + "@fleetbase/ember-core": "^0.3.19", "date-fns": "^2.29.3", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bd9a76..810f131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^7.23.2 version: 7.29.0 '@fleetbase/ember-core': - specifier: ^0.3.18 - version: 0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) + specifier: ^0.3.19 + version: 0.3.19(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) date-fns: specifier: ^2.29.3 version: 2.30.0 @@ -954,8 +954,8 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@fleetbase/ember-core@0.3.18': - resolution: {integrity: sha512-XA/Ysn3NlM37qK/xJCY+Uo2sZ8JTwcDaGruPi8dSVyGfYHO55m96TepCChEU18GdwosbsBBfL8C2R+fUvPsqIg==} + '@fleetbase/ember-core@0.3.19': + resolution: {integrity: sha512-5phquVcfcpRtoxBvyAYDS9bmdPx5c46mXLrcvjmTFSdGElw9CZv44dRkuE8UTgpapMFfPc7vkA9/KwKFAgQ9JA==} engines: {node: '>= 18'} '@formatjs/ecma402-abstract@2.2.4': @@ -7543,7 +7543,7 @@ snapshots: '@eslint/js@8.57.1': {} - '@fleetbase/ember-core@0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': + '@fleetbase/ember-core@0.3.19(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': dependencies: '@babel/core': 7.29.0 compress-json: 3.4.0 @@ -7560,12 +7560,11 @@ snapshots: ember-intl: 6.3.2(@babel/core@7.29.0)(webpack@5.106.2(postcss@8.5.14)) ember-loading: 2.0.0(@babel/core@7.29.0) ember-local-storage: 2.0.7(@babel/core@7.29.0) - ember-simple-auth: 6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1) + ember-simple-auth: 6.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1) ember-wormhole: 0.6.1 socketcluster-client: 17.2.2 transitivePeerDependencies: - '@ember/string' - - '@ember/test-helpers' - '@glint/template' - bufferutil - ember-resolver @@ -10327,7 +10326,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-simple-auth@6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1): + ember-simple-auth@6.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1): dependencies: '@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@8.57.1) '@ember/test-waiters': 3.1.0 @@ -10336,8 +10335,6 @@ snapshots: ember-cli-is-package-missing: 1.0.0 ember-cookies: 1.3.0(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))) silent-error: 1.1.1 - optionalDependencies: - '@ember/test-helpers': 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)) transitivePeerDependencies: - '@babel/core' - '@glint/template' diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 5559640..2c1ea79 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -51,6 +51,103 @@ module('Unit | Model | service rate', function (hooks) { ); }); + test('rateFees returns multi-zone distance rules sorted by priority', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'multi_zone_distance', + }); + + serviceRate.rate_fees.pushObjects([ + store.createRecord('service-rate-fee', { label: 'Fallback', unit: 'multi_zone_distance', priority: 0, is_fallback: true }), + store.createRecord('service-rate-fee', { label: 'Main City', unit: 'multi_zone_distance', priority: 20 }), + store.createRecord('service-rate-fee', { distance: 0, fee: 50 }), + store.createRecord('service-rate-fee', { label: 'Remote', unit: 'multi_zone_distance', priority: 10 }), + ]); + + assert.deepEqual( + serviceRate.rateFees.map((fee) => fee.label), + ['Main City', 'Remote', 'Fallback'] + ); + }); + + test('rateFees prefers persisted multi-zone fees over duplicate unsaved rows', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'multi_zone_distance', + }); + + const unsavedRule = store.createRecord('service-rate-fee', { + label: 'Main City', + service_area_uuid: 'service-area-1', + priority: 10, + unit: 'multi_zone_distance', + fee: '0', + }); + + const persistedRule = store.push({ + data: { + type: 'service-rate-fee', + id: 'rate-fee-1', + attributes: { + label: 'Main City', + service_area_uuid: 'service-area-1', + priority: 10, + unit: 'multi_zone_distance', + fee: '2', + }, + }, + }); + + serviceRate.rate_fees.pushObjects([unsavedRule, persistedRule]); + + assert.strictEqual(serviceRate.rateFees.length, 1); + assert.strictEqual(serviceRate.rateFees[0].id, 'rate-fee-1'); + assert.strictEqual(serviceRate.rateFees[0].fee, '2'); + }); + + test('rateFees prefers the latest duplicate persisted multi-zone fee', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'multi_zone_distance', + }); + + const updatedRule = store.push({ + data: { + type: 'service-rate-fee', + id: 'rate-fee-updated', + attributes: { + label: 'Main City', + service_area_uuid: 'service-area-1', + priority: 10, + unit: 'multi_zone_distance', + fee: '300', + updated_at: new Date('2026-05-22T04:45:00.000Z'), + }, + }, + }); + + const staleRule = store.push({ + data: { + type: 'service-rate-fee', + id: 'rate-fee-stale', + attributes: { + label: 'Main City', + service_area_uuid: 'service-area-1', + priority: 10, + unit: 'multi_zone_distance', + fee: '0', + updated_at: new Date('2026-05-22T04:40:00.000Z'), + }, + }, + }); + + serviceRate.rate_fees.pushObjects([updatedRule, staleRule]); + + assert.strictEqual(serviceRate.rateFees.length, 1); + assert.strictEqual(serviceRate.rateFees[0].id, 'rate-fee-updated'); + assert.strictEqual(serviceRate.rateFees[0].fee, '300'); + }); + test('parcelFees prefers persisted parcel fees over duplicate unsaved defaults', function (assert) { const store = this.owner.lookup('service:store'); const serviceRate = store.createRecord('service-rate', { @@ -153,6 +250,24 @@ module('Unit | Model | service rate', function (hooks) { assert.strictEqual(addedFee.max, 8); }); + test('addMultiZoneDistanceRule creates generic geographic pricing rules', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'multi_zone_distance', + currency: 'SAR', + }); + + serviceRate.addMultiZoneDistanceRule({ label: 'Main City', fee: 250 }); + serviceRate.addMultiZoneDistanceFallbackRule(); + + assert.strictEqual(serviceRate.rate_fees.length, 2); + assert.strictEqual(serviceRate.rate_fees[0].unit, 'multi_zone_distance'); + assert.strictEqual(serviceRate.rate_fees[0].distance_unit, 'km'); + assert.strictEqual(serviceRate.rate_fees[0].currency, 'SAR'); + assert.false(serviceRate.rate_fees[0].is_fallback); + assert.true(serviceRate.rate_fees[1].is_fallback); + }); + test('rateFees prefers persisted per-drop fees over duplicate unsaved rows', function (assert) { const store = this.owner.lookup('service:store'); const serviceRate = store.createRecord('service-rate', {