Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion addon/models/service-rate-fee.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
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';

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;
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 96 additions & 1 deletion addon/models/service-rate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -214,6 +268,8 @@ export default class ServiceRate extends Model {
});

this.rate_fees.addObject(newFee);

return newFee;
}

@action removePerDropFee(fee) {
Expand All @@ -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();
}
}
9 changes: 8 additions & 1 deletion addon/serializers/service-rate-fee.js
Original file line number Diff line number Diff line change
@@ -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 },
};
}
}
7 changes: 6 additions & 1 deletion addon/serializers/service-rate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
115 changes: 115 additions & 0 deletions tests/unit/models/service-rate-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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', {
Expand Down
Loading