From 4814534cee3408025d7301482a052f9715e1ff6f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 12:49:07 +0800 Subject: [PATCH 1/6] feat: fix service rate editor persistence --- addon/models/service-rate.js | 12 +++++--- addon/serializers/service-rate.js | 18 +++++++++-- tests/unit/models/service-rate-test.js | 41 +++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 2162703..30915fb 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -116,12 +116,16 @@ export default class ServiceRate extends Model { return this.cod_calculation_method === 'percentage'; } - @computed('rate_fees.@each.distance', 'max_distance') get rateFees() { + @computed('rate_fees.@each.{distance,min,max,unit}', 'max_distance', 'rate_calculation_method', 'isPerDrop') get rateFees() { + const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => !r.isDeleted); + + if (this.isPerDrop) { + return existing.filter((r) => r.unit === 'waypoint').sort((a, b) => (a.min ?? 0) - (b.min ?? 0)); + } + const n = Math.max(0, Number(this.max_distance) || 0); - const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => r.distance !== null && r.distance !== undefined && !r.isDeleted); - // Return existing fees sorted by distance, filtered by max_distance - return existing.filter((r) => r.distance >= 0 && r.distance < n).sort((a, b) => a.distance - b.distance); + return existing.filter((r) => r.distance !== null && r.distance !== undefined && r.distance >= 0 && r.distance < n).sort((a, b) => a.distance - b.distance); } /** @methods */ diff --git a/addon/serializers/service-rate.js b/addon/serializers/service-rate.js index 7f1ebeb..9e106bc 100644 --- a/addon/serializers/service-rate.js +++ b/addon/serializers/service-rate.js @@ -38,12 +38,24 @@ export default class ServiceRateSerializer extends ApplicationSerializer.extend( const savedRateFees = allRateFees.filter((f) => !f.isNew); const unsavedRateFees = allRateFees.filter((f) => f.isNew); - // Create a map of saved fees by distance - const savedByDistance = new Map(savedRateFees.map((f) => [f.distance, f])); + // Create a map of saved fees using the most stable key for the fee shape. + const savedFeeKey = (fee) => { + if (fee.id) { + return `id:${fee.id}`; + } + + if (fee.unit === 'waypoint') { + return `drop:${fee.min}:${fee.max}:${fee.unit}`; + } + + return `distance:${fee.distance}`; + }; + + const savedByKey = new Map(savedRateFees.map((f) => [savedFeeKey(f), f])); // Only remove unsaved fees that duplicate saved fees unsavedRateFees.forEach((fee) => { - if (savedByDistance.has(fee.distance)) { + if (savedByKey.has(savedFeeKey(fee))) { serviceRate.get('rate_fees').removeObject(fee); fee.unloadRecord(); } diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 8a69d93..d4dac33 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -5,10 +5,49 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Model | service rate', function (hooks) { setupTest(hooks); - // Replace this with your real tests. test('it exists', function (assert) { let store = this.owner.lookup('service:store'); let model = store.createRecord('service-rate', {}); assert.ok(model); }); + + test('rateFees returns per-drop fees sorted by min when using per_drop', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'per_drop', + }); + + serviceRate.rate_fees.pushObjects([ + store.createRecord('service-rate-fee', { min: 6, max: 10, unit: 'waypoint', fee: 200 }), + store.createRecord('service-rate-fee', { min: 1, max: 5, unit: 'waypoint', fee: 100 }), + store.createRecord('service-rate-fee', { distance: 0, fee: 50 }), + ]); + + assert.deepEqual( + serviceRate.rateFees.map((fee) => [fee.min, fee.max]), + [ + [1, 5], + [6, 10], + ] + ); + }); + + test('rateFees filters fixed-distance fees by max_distance', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'fixed_rate', + max_distance: 2, + }); + + serviceRate.rate_fees.pushObjects([ + store.createRecord('service-rate-fee', { distance: 0, fee: 100 }), + store.createRecord('service-rate-fee', { distance: 1, fee: 200 }), + store.createRecord('service-rate-fee', { distance: 2, fee: 300 }), + ]); + + assert.deepEqual( + serviceRate.rateFees.map((fee) => fee.distance), + [0, 1] + ); + }); }); From 7379707d866d313ec5450685f55ecc24f575565a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 13:52:03 +0800 Subject: [PATCH 2/6] fix: dedupe parcel fee records in service rate model --- addon/models/service-rate.js | 32 ++++++++++++++++++++ tests/unit/models/service-rate-test.js | 41 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 30915fb..a75278c 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -128,6 +128,38 @@ export default class ServiceRate extends Model { return existing.filter((r) => r.distance !== null && r.distance !== undefined && r.distance >= 0 && r.distance < n).sort((a, b) => a.distance - b.distance); } + @computed('parcel_fees.@each.{size,length,width,height,dimensions_unit,weight,weight_unit,fee,id}') get parcelFees() { + const existing = (this.parcel_fees?.toArray?.() ?? []).filter((fee) => !fee.isDeleted); + const deduped = new Map(); + + const feeKey = (fee) => { + return [fee.size, fee.length, fee.width, fee.height, fee.dimensions_unit, fee.weight, fee.weight_unit].join(':'); + }; + + const rankFee = (fee) => { + if (fee.id && !fee.isNew) { + return 3; + } + + if (!fee.isNew) { + return 2; + } + + return 1; + }; + + existing.forEach((fee) => { + const key = feeKey(fee); + const current = deduped.get(key); + + if (!current || rankFee(fee) > rankFee(current)) { + deduped.set(key, fee); + } + }); + + return Array.from(deduped.values()); + } + /** @methods */ @action createDefaultPerDropFee(attributes = {}) { const store = getOwner(this).lookup('service:store'); diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index d4dac33..118dde2 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -50,4 +50,45 @@ module('Unit | Model | service rate', function (hooks) { [0, 1] ); }); + + 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', { + rate_calculation_method: 'parcel', + }); + + const unsavedDefault = store.createRecord('service-rate-parcel-fee', { + size: 'small', + length: 34, + width: 18, + height: 10, + dimensions_unit: 'cm', + weight: 2, + weight_unit: 'kg', + fee: 0, + }); + + const persistedFee = store.push({ + data: { + type: 'service-rate-parcel-fee', + id: 'parcel-fee-1', + attributes: { + size: 'small', + length: '34', + width: '18', + height: '10', + dimensions_unit: 'cm', + weight: '2', + weight_unit: 'kg', + fee: '5', + }, + }, + }); + + serviceRate.parcel_fees.pushObjects([unsavedDefault, persistedFee]); + + assert.strictEqual(serviceRate.parcelFees.length, 1); + assert.strictEqual(serviceRate.parcelFees[0].id, 'parcel-fee-1'); + assert.strictEqual(serviceRate.parcelFees[0].fee, '5'); + }); }); From b626ab30cf94af497c75be3ab6805a3ee3fc7b79 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 13:57:38 +0800 Subject: [PATCH 3/6] fix: increment per-drop ranges numerically --- addon/models/service-rate.js | 3 ++- tests/unit/models/service-rate-test.js | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index a75278c..2767772 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -177,7 +177,8 @@ export default class ServiceRate extends Model { const store = getOwner(this).lookup('service:store'); const existingFees = this.rate_fees?.toArray?.() ?? []; const last = existingFees[existingFees.length - 1]; - const min = last ? last.max + 1 : 1; + const lastMax = Number(last?.max) || 0; + const min = last ? lastMax + 1 : 1; const max = min + 5; const newFee = store.createRecord('service-rate-fee', { diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 118dde2..9686328 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -91,4 +91,28 @@ module('Unit | Model | service rate', function (hooks) { assert.strictEqual(serviceRate.parcelFees[0].id, 'parcel-fee-1'); assert.strictEqual(serviceRate.parcelFees[0].fee, '5'); }); + + test('addPerDropRateFee increments numeric ranges even when existing values are strings', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'per_drop', + currency: 'USD', + }); + + serviceRate.rate_fees.pushObject( + store.createRecord('service-rate-fee', { + min: '1', + max: '2', + unit: 'waypoint', + fee: 100, + }) + ); + + serviceRate.addPerDropRateFee(); + + const addedFee = serviceRate.rate_fees[1]; + + assert.strictEqual(addedFee.min, 3); + assert.strictEqual(addedFee.max, 8); + }); }); From 2beb4f457edc03c15e304b1c0dc950769323c5b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 14:00:58 +0800 Subject: [PATCH 4/6] fix: dedupe persisted per-drop fee records --- addon/models/service-rate.js | 26 +++++++++++++++++++- tests/unit/models/service-rate-test.js | 33 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 2767772..7a53c65 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -120,7 +120,31 @@ export default class ServiceRate extends Model { const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => !r.isDeleted); if (this.isPerDrop) { - return existing.filter((r) => r.unit === 'waypoint').sort((a, b) => (a.min ?? 0) - (b.min ?? 0)); + const deduped = new Map(); + const rankFee = (fee) => { + if (fee.id && !fee.isNew) { + return 3; + } + + if (!fee.isNew) { + return 2; + } + + return 1; + }; + + existing + .filter((r) => r.unit === 'waypoint') + .forEach((fee) => { + const key = `drop:${fee.min}:${fee.max}:${fee.unit}`; + const current = deduped.get(key); + + if (!current || rankFee(fee) > rankFee(current)) { + deduped.set(key, fee); + } + }); + + return Array.from(deduped.values()).sort((a, b) => (a.min ?? 0) - (b.min ?? 0)); } const n = Math.max(0, Number(this.max_distance) || 0); diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 9686328..8846977 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -115,4 +115,37 @@ module('Unit | Model | service rate', function (hooks) { assert.strictEqual(addedFee.min, 3); assert.strictEqual(addedFee.max, 8); }); + + 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', { + rate_calculation_method: 'per_drop', + }); + + const unsavedDefault = store.createRecord('service-rate-fee', { + min: 1, + max: 5, + unit: 'waypoint', + fee: 0, + }); + + const persistedFee = store.push({ + data: { + type: 'service-rate-fee', + id: 'rate-fee-1', + attributes: { + min: 1, + max: 5, + unit: 'waypoint', + fee: '5', + }, + }, + }); + + serviceRate.rate_fees.pushObjects([unsavedDefault, persistedFee]); + + assert.strictEqual(serviceRate.rateFees.length, 1); + assert.strictEqual(serviceRate.rateFees[0].id, 'rate-fee-1'); + assert.strictEqual(serviceRate.rateFees[0].fee, '5'); + }); }); From 5281ee2f0bbb9fe7fa54e3a3cf1f7d94b2e2eda2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 23 Apr 2026 14:21:34 +0800 Subject: [PATCH 5/6] fix: send child fee uuids when saving service rates --- addon/models/service-rate-fee.js | 2 ++ addon/models/service-rate-parcel-fee.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/addon/models/service-rate-fee.js b/addon/models/service-rate-fee.js index a512e1d..1cce7c6 100644 --- a/addon/models/service-rate-fee.js +++ b/addon/models/service-rate-fee.js @@ -4,6 +4,7 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro export default class ServiceRateFeeModel extends Model { /** @ids */ + @attr('string') uuid; @attr('string') service_rate_uuid; /** @attributes */ @@ -66,6 +67,7 @@ export default class ServiceRateFeeModel extends Model { /** @methods */ toJSON() { return { + uuid: this.uuid, service_rate_uuid: this.service_rate_uuid, distance: this.distance, distance_unit: this.distance_unit, diff --git a/addon/models/service-rate-parcel-fee.js b/addon/models/service-rate-parcel-fee.js index 96e72d7..56ff1ad 100644 --- a/addon/models/service-rate-parcel-fee.js +++ b/addon/models/service-rate-parcel-fee.js @@ -4,6 +4,7 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro export default class ServiceRateParcelFeeModel extends Model { /** @ids */ + @attr('string') uuid; @attr('string') service_rate_uuid; /** @attributes */ @@ -68,6 +69,7 @@ export default class ServiceRateParcelFeeModel extends Model { /** @methods */ toJSON() { return { + uuid: this.uuid, service_rate_uuid: this.service_rate_uuid, size: this.size, length: this.length, From cb7025df8dfde45a6a107ef3d26b653543ec707f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 24 Apr 2026 11:21:32 +0800 Subject: [PATCH 6/6] bump to v0.1.30 --- addon/models/service-rate.js | 4 +-- package.json | 2 +- tests/unit/models/service-rate-test.js | 37 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 7a53c65..80e1e24 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -139,7 +139,7 @@ export default class ServiceRate extends Model { const key = `drop:${fee.min}:${fee.max}:${fee.unit}`; const current = deduped.get(key); - if (!current || rankFee(fee) > rankFee(current)) { + if (!current || rankFee(fee) >= rankFee(current)) { deduped.set(key, fee); } }); @@ -176,7 +176,7 @@ export default class ServiceRate extends Model { const key = feeKey(fee); const current = deduped.get(key); - if (!current || rankFee(fee) > rankFee(current)) { + if (!current || rankFee(fee) >= rankFee(current)) { deduped.set(key, fee); } }); diff --git a/package.json b/package.json index 47e9762..47bb20f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-data", - "version": "0.1.29", + "version": "0.1.30", "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters and GeoJson utility functions.", "keywords": [ "fleetbase-data", diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 8846977..5559640 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -92,6 +92,43 @@ module('Unit | Model | service rate', function (hooks) { assert.strictEqual(serviceRate.parcelFees[0].fee, '5'); }); + test('parcelFees prefers the latest duplicate persisted parcel fee in store state', function (assert) { + const store = this.owner.lookup('service:store'); + const serviceRate = store.createRecord('service-rate', { + rate_calculation_method: 'parcel', + }); + + const staleFee = store.createRecord('service-rate-parcel-fee', { + size: 'small', + length: 34, + width: 18, + height: 10, + dimensions_unit: 'cm', + weight: 2, + weight_unit: 'kg', + fee: '0', + }); + staleFee.set('id', 'parcel-fee-stale'); + + const updatedFee = store.createRecord('service-rate-parcel-fee', { + size: 'small', + length: 34, + width: 18, + height: 10, + dimensions_unit: 'cm', + weight: 2, + weight_unit: 'kg', + fee: '12', + }); + updatedFee.set('id', 'parcel-fee-updated'); + + serviceRate.parcel_fees.pushObjects([staleFee, updatedFee]); + + assert.strictEqual(serviceRate.parcelFees.length, 1); + assert.strictEqual(serviceRate.parcelFees[0].id, 'parcel-fee-updated'); + assert.strictEqual(serviceRate.parcelFees[0].fee, '12'); + }); + test('addPerDropRateFee increments numeric ranges even when existing values are strings', function (assert) { const store = this.owner.lookup('service:store'); const serviceRate = store.createRecord('service-rate', {