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, diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 2162703..80e1e24 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -116,12 +116,72 @@ 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) { + 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); - 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); + } + + @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 */ @@ -141,7 +201,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/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/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 8a69d93..5559640 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -5,10 +5,184 @@ 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] + ); + }); + + 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'); + }); + + 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', { + 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); + }); + + 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'); + }); });