From dcef723a3089a58d474147159300f17e327b495f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 22 May 2026 11:08:46 +0800 Subject: [PATCH 1/5] Support multi-zone service rate rules --- addon/models/service-rate-fee.js | 16 +++++++- addon/models/service-rate.js | 52 +++++++++++++++++++++++++- addon/serializers/service-rate-fee.js | 9 ++++- addon/serializers/service-rate.js | 4 ++ tests/unit/models/service-rate-test.js | 37 ++++++++++++++++++ 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/addon/models/service-rate-fee.js b/addon/models/service-rate-fee.js index 1cce7c6..9230c37 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; @@ -69,6 +78,11 @@ export default class ServiceRateFeeModel extends Model { 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..c6c1523 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,14 @@ 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.{distance,min,max,unit,priority,is_fallback,zone_uuid,service_area_uuid}', 'max_distance', 'rate_calculation_method', 'isPerDrop', 'isMultiZoneDistance') + get rateFees() { const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => !r.isDeleted); + if (this.isMultiZoneDistance) { + return existing.filter((r) => r.unit === 'multi_zone_distance').sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); + } + if (this.isPerDrop) { const deduped = new Map(); const rankFee = (fee) => { @@ -214,6 +223,8 @@ export default class ServiceRate extends Model { }); this.rate_fees.addObject(newFee); + + return newFee; } @action removePerDropFee(fee) { @@ -236,4 +247,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/serializers/service-rate-fee.js b/addon/serializers/service-rate-fee.js index 117b548..bf003d2 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' }, + zone: { embedded: 'always' }, + }; + } +} diff --git a/addon/serializers/service-rate.js b/addon/serializers/service-rate.js index 9e106bc..25b8651 100644 --- a/addon/serializers/service-rate.js +++ b/addon/serializers/service-rate.js @@ -48,6 +48,10 @@ 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}`; }; diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 5559640..908e1e5 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -51,6 +51,25 @@ 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('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 +172,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', { From c62131bcd1d5ce6dabc9c686a584733ed45d378a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 22 May 2026 12:23:36 +0800 Subject: [PATCH 2/5] Infer multi-zone geography type --- addon/models/service-rate-fee.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/addon/models/service-rate-fee.js b/addon/models/service-rate-fee.js index 9230c37..6ce8d89 100644 --- a/addon/models/service-rate-fee.js +++ b/addon/models/service-rate-fee.js @@ -73,6 +73,18 @@ 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 { From beb8270b776f54dc4da01e8b3ab2f6fac9e4f4a9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 22 May 2026 12:50:11 +0800 Subject: [PATCH 3/5] Avoid serializing embedded rate fee geography --- addon/serializers/service-rate-fee.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addon/serializers/service-rate-fee.js b/addon/serializers/service-rate-fee.js index bf003d2..49913c4 100644 --- a/addon/serializers/service-rate-fee.js +++ b/addon/serializers/service-rate-fee.js @@ -4,8 +4,8 @@ import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; export default class ServiceRateFeeSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { get attrs() { return { - service_area: { embedded: 'always' }, - zone: { embedded: 'always' }, + service_area: { embedded: 'always', serialize: false }, + zone: { embedded: 'always', serialize: false }, }; } } From 4167ed58e10b6bb6c09e37a72db436be66fbfcad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 22 May 2026 12:59:57 +0800 Subject: [PATCH 4/5] Deduplicate multi-zone rate fees after save --- addon/models/service-rate.js | 33 +++++++++++++++++++++++- addon/serializers/service-rate.js | 3 ++- tests/unit/models/service-rate-test.js | 35 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index c6c1523..032a358 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -125,7 +125,38 @@ export default class ServiceRate extends Model { const existing = (this.rate_fees?.toArray?.() ?? []).filter((r) => !r.isDeleted); if (this.isMultiZoneDistance) { - return existing.filter((r) => r.unit === 'multi_zone_distance').sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); + const deduped = new Map(); + const rankFee = (fee) => { + if (fee.id && !fee.isNew) { + return 3; + } + + if (!fee.isNew) { + return 2; + } + + return 1; + }; + 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); + + if (!current || rankFee(fee) >= rankFee(current)) { + deduped.set(key, fee); + } + }); + + return Array.from(deduped.values()).sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)); } if (this.isPerDrop) { diff --git a/addon/serializers/service-rate.js b/addon/serializers/service-rate.js index 25b8651..310e11c 100644 --- a/addon/serializers/service-rate.js +++ b/addon/serializers/service-rate.js @@ -56,10 +56,11 @@ export default class ServiceRateSerializer extends ApplicationSerializer.extend( }; 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/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 908e1e5..6f891dd 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -70,6 +70,41 @@ module('Unit | Model | service rate', function (hooks) { ); }); + 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('parcelFees prefers persisted parcel fees over duplicate unsaved defaults', function (assert) { const store = this.owner.lookup('service:store'); const serviceRate = store.createRecord('service-rate', { From 27fd6ce6e5b3701e278ba9672a2f5d9c7629bf84 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 22 May 2026 14:20:17 +0800 Subject: [PATCH 5/5] Refresh multi-zone fee values after edit --- addon/models/service-rate.js | 18 +++++++++-- tests/unit/models/service-rate-test.js | 43 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/addon/models/service-rate.js b/addon/models/service-rate.js index 032a358..915dbf6 100644 --- a/addon/models/service-rate.js +++ b/addon/models/service-rate.js @@ -120,7 +120,13 @@ export default class ServiceRate extends Model { return this.cod_calculation_method === 'percentage'; } - @computed('rate_fees.@each.{distance,min,max,unit,priority,is_fallback,zone_uuid,service_area_uuid}', 'max_distance', 'rate_calculation_method', 'isPerDrop', 'isMultiZoneDistance') + @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); @@ -137,6 +143,12 @@ export default class ServiceRate extends Model { 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'; @@ -150,8 +162,10 @@ export default class ServiceRate extends Model { .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 || rankFee(fee) >= rankFee(current)) { + if (!current || feeRank > currentRank || (feeRank === currentRank && updatedAtMs(fee) >= updatedAtMs(current))) { deduped.set(key, fee); } }); diff --git a/tests/unit/models/service-rate-test.js b/tests/unit/models/service-rate-test.js index 6f891dd..2c1ea79 100644 --- a/tests/unit/models/service-rate-test.js +++ b/tests/unit/models/service-rate-test.js @@ -105,6 +105,49 @@ module('Unit | Model | service rate', function (hooks) { 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', {