From 31e1ee9ac7a6624cd392d2c369857e29143b0ca2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 3 Sep 2026 17:02:07 +0800 Subject: [PATCH 1/3] feat: add trailer data models and serializers --- addon/models/asset-connection.js | 25 ++++++ addon/models/asset.js | 16 +++- addon/models/attachable-trailer.js | 11 +++ addon/models/attachable.js | 2 +- addon/models/equipment.js | 1 + addon/models/maintenance-subject-trailer.js | 15 ++++ addon/models/maintenance-subject.js | 4 +- addon/models/trailer.js | 90 +++++++++++++++++++ addon/models/vehicle.js | 3 + addon/serializers/asset-connection.js | 11 +++ addon/serializers/device.js | 2 +- addon/serializers/equipment.js | 65 +++++++++++++- addon/serializers/trailer.js | 18 ++++ app/models/asset-connection.js | 1 + app/models/attachable-trailer.js | 1 + app/models/maintenance-subject-trailer.js | 1 + app/models/trailer.js | 1 + app/serializers/asset-connection.js | 1 + app/serializers/trailer.js | 1 + package.json | 1 + pnpm-lock.yaml | 3 + tests/unit/models/asset-connection-test.js | 15 ++++ tests/unit/models/trailer-test.js | 23 +++++ .../unit/serializers/asset-connection-test.js | 14 +++ tests/unit/serializers/device-test.js | 25 ++++++ tests/unit/serializers/equipment-test.js | 25 ++++++ tests/unit/serializers/trailer-test.js | 17 ++++ 27 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 addon/models/asset-connection.js create mode 100644 addon/models/attachable-trailer.js create mode 100644 addon/models/maintenance-subject-trailer.js create mode 100644 addon/models/trailer.js create mode 100644 addon/serializers/asset-connection.js create mode 100644 addon/serializers/trailer.js create mode 100644 app/models/asset-connection.js create mode 100644 app/models/attachable-trailer.js create mode 100644 app/models/maintenance-subject-trailer.js create mode 100644 app/models/trailer.js create mode 100644 app/serializers/asset-connection.js create mode 100644 app/serializers/trailer.js create mode 100644 tests/unit/models/asset-connection-test.js create mode 100644 tests/unit/models/trailer-test.js create mode 100644 tests/unit/serializers/asset-connection-test.js create mode 100644 tests/unit/serializers/trailer-test.js diff --git a/addon/models/asset-connection.js b/addon/models/asset-connection.js new file mode 100644 index 0000000..9c0ec65 --- /dev/null +++ b/addon/models/asset-connection.js @@ -0,0 +1,25 @@ +import Model, { attr, belongsTo } from '@ember-data/model'; + +export default class AssetConnectionModel extends Model { + @attr('string') uuid; + @attr('string') public_id; + @attr('string') company_uuid; + @attr('string') connector_type; + @attr('string') connector_uuid; + @attr('string') connected_type; + @attr('string') connected_uuid; + @attr('string', { defaultValue: 'towing' }) relationship_type; + @attr('number', { defaultValue: 1 }) position; + @attr('string') source; + @attr('string') confidence; + @attr('string') notes; + @attr('raw') meta; + @attr('boolean') active; + @attr('date') connected_at; + @attr('date') disconnected_at; + @attr('date') created_at; + @attr('date') updated_at; + + @belongsTo('vehicle', { async: false }) vehicle; + @belongsTo('trailer', { async: false }) trailer; +} diff --git a/addon/models/asset.js b/addon/models/asset.js index 8387795..dc7ee61 100644 --- a/addon/models/asset.js +++ b/addon/models/asset.js @@ -55,10 +55,22 @@ export default class AssetModel extends Model { @attr('string') odometer_unit; @attr('string') transmission; @attr('string') fuel_volume_unit; - @attr('string') fuel_Type; + @attr('string') fuel_type; @attr('string') ownership_type; @attr('string') engine_hours; @attr('string') gvw; + @attr('number') width; + @attr('number') length; + @attr('number') height; + @attr('number') tare_weight; + @attr('number') gvwr; + @attr('number') payload_capacity; + @attr('number') cargo_volume; + @attr('string') currency; + @attr('string') acquisition_cost; + @attr('string') current_value; + @attr('string') insurance_value; + @attr('string') depreciation_rate; @attr('raw') capacity; @attr('raw') specs; @attr('raw') attributes; @@ -77,6 +89,8 @@ export default class AssetModel extends Model { /** @dates */ @attr('date') deleted_at; + @attr('date') purchased_at; + @attr('date') lease_expires_at; @attr('date') created_at; @attr('date') updated_at; diff --git a/addon/models/attachable-trailer.js b/addon/models/attachable-trailer.js new file mode 100644 index 0000000..68bc3e9 --- /dev/null +++ b/addon/models/attachable-trailer.js @@ -0,0 +1,11 @@ +import AttachableAssetModel from './attachable-asset'; +import { attr } from '@ember-data/model'; + +/** Concrete polymorphic model for a Trailer attached to a device. */ +export default class AttachableTrailerModel extends AttachableAssetModel { + @attr('string') body_type; + @attr('string') coupling_type; + @attr('number') axle_count; + @attr('boolean') refrigerated; + @attr('string') current_vehicle_name; +} diff --git a/addon/models/attachable.js b/addon/models/attachable.js index 6e7b2c2..d366768 100644 --- a/addon/models/attachable.js +++ b/addon/models/attachable.js @@ -4,7 +4,7 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro /** * Abstract base model for resources a device can be attached to. - * Concrete types: attachable-vehicle, attachable-asset. + * Concrete types: attachable-vehicle, attachable-asset, attachable-trailer. */ export default class AttachableModel extends Model { /** @ids */ diff --git a/addon/models/equipment.js b/addon/models/equipment.js index a897ade..c2b82ad 100644 --- a/addon/models/equipment.js +++ b/addon/models/equipment.js @@ -15,6 +15,7 @@ export default class EquipmentModel extends Model { /** @relationships */ @belongsTo('warranty', { async: false }) warranty; @belongsTo('file', { async: false }) photo; + @belongsTo('attachable', { polymorphic: true, async: false }) equipable; @hasMany('maintenance', { async: false }) maintenances; @hasMany('custom-field-value', { async: false }) custom_field_values; diff --git a/addon/models/maintenance-subject-trailer.js b/addon/models/maintenance-subject-trailer.js new file mode 100644 index 0000000..f24a4a8 --- /dev/null +++ b/addon/models/maintenance-subject-trailer.js @@ -0,0 +1,15 @@ +import MaintenanceSubjectModel from './maintenance-subject'; +import { attr } from '@ember-data/model'; + +/** Concrete polymorphic model for Trailer maintenance targets. */ +export default class MaintenanceSubjectTrailerModel extends MaintenanceSubjectModel { + @attr('string') code; + @attr('string') vin; + @attr('string') plate_number; + @attr('string') make; + @attr('string') model; + @attr('string') year; + @attr('string') body_type; + @attr('number') axle_count; + @attr('string') current_vehicle_name; +} diff --git a/addon/models/maintenance-subject.js b/addon/models/maintenance-subject.js index efaff85..7b584d2 100644 --- a/addon/models/maintenance-subject.js +++ b/addon/models/maintenance-subject.js @@ -4,10 +4,12 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro /** * Abstract base model for polymorphic maintenance subjects. - * Concrete types: maintenance-subject-vehicle, maintenance-subject-equipment + * Concrete types: maintenance-subject-vehicle, maintenance-subject-trailer, + * maintenance-subject-equipment * * The backend stores the type as a PolymorphicType cast string, e.g.: * 'fleet-ops:vehicle' -> maintenance-subject-vehicle + * 'fleet-ops:trailer' -> maintenance-subject-trailer * 'fleet-ops:equipment' -> maintenance-subject-equipment */ export default class MaintenanceSubjectModel extends Model { diff --git a/addon/models/trailer.js b/addon/models/trailer.js new file mode 100644 index 0000000..ea04c75 --- /dev/null +++ b/addon/models/trailer.js @@ -0,0 +1,90 @@ +import AssetModel from './asset'; +import { attr, belongsTo, hasMany } from '@ember-data/model'; +import { computed, get } from '@ember/object'; +import { not } from '@ember/object/computed'; +import isValidCoordinates from '@fleetbase/ember-core/utils/is-valid-coordinates'; + +/** + * A first-class towed fleet asset. + * + * Trailer records share the common Asset contract while exposing the + * operational, connection, capacity, and telemetry fields used by Fleet-Ops. + */ +export default class TrailerModel extends AssetModel { + /** @relationships */ + @belongsTo('vehicle', { async: false, inverse: null }) current_vehicle; + @belongsTo('asset-connection', { async: false, inverse: null }) current_connection; + @hasMany('asset-connection', { async: false, inverse: null }) connections; + @hasMany('maintenance-schedule', { async: false, inverse: null }) maintenance_schedules; + @hasMany('work-order', { async: false, inverse: null }) work_orders; + @hasMany('position', { async: false, inverse: null }) positions; + + /** @classification */ + @attr('string', { defaultValue: 'trailer' }) asset_class; + @attr('string') body_type; + @attr('string') coupling_type; + @attr('string') brake_type; + + /** @capacity and dimensions */ + @attr('number') length; + @attr('number') width; + @attr('number') height; + @attr('number') tare_weight; + @attr('number') gvwr; + @attr('number') payload_capacity; + @attr('number') cargo_volume; + @attr('number') axle_count; + @attr('number') tire_count; + @attr('number') door_count; + + /** @specialized trailer capabilities */ + @attr('boolean') abs_equipped; + @attr('boolean') ebs_equipped; + @attr('boolean') refrigerated; + @attr('number') temperature_min; + @attr('number') temperature_max; + @attr('number') reefer_engine_hours; + + /** @current operational projections */ + @attr('boolean') online; + @attr('string') connectivity_status; + @attr('string') movement_status; + @attr('raw') telematics; + @attr('raw') resolved_location; + @attr('string') current_vehicle_name; + @attr('string') current_vehicle_id; + @attr('date') attached_at; + @attr('number') devices_count; + @attr('number') equipment_count; + @attr('date') last_online_at; + + @computed('name', 'display_name', 'code', 'plate_number', 'vin', 'serial_number', 'yearMakeModel') get searchString() { + return [this.name, this.display_name, this.code, this.plate_number, this.vin, this.serial_number, this.yearMakeModel].filter(Boolean).join(' '); + } + + @computed('location') get longitude() { + return get(this.location, 'coordinates.0'); + } + + @computed('location') get latitude() { + return get(this.location, 'coordinates.1'); + } + + @computed('latitude', 'longitude') get coordinates() { + return [get(this, 'latitude'), get(this, 'longitude')]; + } + + @computed('latitude', 'longitude') get latlng() { + return { lat: get(this, 'latitude'), lng: get(this, 'longitude') }; + } + + @computed('coordinates', 'latitude', 'longitude') get hasValidCoordinates() { + if (this.longitude === 0 || this.latitude === 0) { + return false; + } + + return isValidCoordinates(this.coordinates); + } + + @not('hasValidCoordinates') hasInvalidCoordinates; +} diff --git a/addon/models/vehicle.js b/addon/models/vehicle.js index 7874f2c..8ce1772 100644 --- a/addon/models/vehicle.js +++ b/addon/models/vehicle.js @@ -22,6 +22,9 @@ export default class VehicleModel extends Model { @belongsTo('driver', { async: false }) driver; @belongsTo('vendor', { async: false }) vendor; @hasMany('device', { async: false }) devices; + @hasMany('trailer', { async: false }) trailers; + @hasMany('asset-connection', { async: false }) trailer_connections; + @hasMany('equipment', { async: false }) equipments; @hasMany('custom-field-value', { async: false }) custom_field_values; /** @attributes */ diff --git a/addon/serializers/asset-connection.js b/addon/serializers/asset-connection.js new file mode 100644 index 0000000..095c72c --- /dev/null +++ b/addon/serializers/asset-connection.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; + +export default class AssetConnectionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + vehicle: { embedded: 'always' }, + trailer: { embedded: 'always' }, + }; + } +} diff --git a/addon/serializers/device.js b/addon/serializers/device.js index 3672502..cffdde6 100644 --- a/addon/serializers/device.js +++ b/addon/serializers/device.js @@ -81,7 +81,7 @@ export default class DeviceSerializer extends ApplicationSerializer.extend(Embed .replace(/^attachable-/, '') .toLowerCase(); - if (!['vehicle', 'asset'].includes(type)) { + if (!['vehicle', 'asset', 'trailer'].includes(type)) { return undefined; } diff --git a/addon/serializers/equipment.js b/addon/serializers/equipment.js index 8766fa0..f454f2c 100644 --- a/addon/serializers/equipment.js +++ b/addon/serializers/equipment.js @@ -1,4 +1,67 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; +import { isBlank } from '@ember/utils'; -export default class EquipmentSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {} +export default class EquipmentSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + warranty: { embedded: 'always' }, + photo: { embedded: 'always' }, + equipable: { embedded: 'always' }, + custom_field_values: { embedded: 'always' }, + }; + } + + normalize(model, hash, prop) { + const equipableDomainType = hash?.equipable?.type; + + if (hash?.equipable) { + hash.equipable.type = this.equipableModelNameFromType(hash.equipable_type); + } + + const normalized = super.normalize(model, hash, prop); + + if (equipableDomainType && !this.equipableModelNameFromType(equipableDomainType)) { + const equipable = normalized?.data?.relationships?.equipable?.data; + const included = normalized?.included?.find((resource) => resource.type === equipable?.type && resource.id === equipable?.id); + + if (included) { + included.attributes = included.attributes ?? {}; + included.attributes.type = equipableDomainType; + } + } + + return normalized; + } + + serializePolymorphicType(snapshot, json, relationship) { + let key = relationship.key; + + if (key !== 'equipable') { + return typeof super.serializePolymorphicType === 'function' ? super.serializePolymorphicType(...arguments) : undefined; + } + + const belongsTo = snapshot.belongsTo(key); + + if (!isBlank(snapshot.attr(`${key}_type`))) { + return; + } + + key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; + json[`${key}_type`] = belongsTo ? `fleet-ops:${belongsTo.modelName.replace(/^attachable-/, '')}` : null; + } + + equipableModelNameFromType(type) { + if (!type || typeof type !== 'string') { + return undefined; + } + + const normalized = type + .split('\\') + .pop() + .replace(/^fleet-ops:/, '') + .replace(/^attachable-/, '') + .toLowerCase(); + return ['vehicle', 'trailer', 'driver', 'asset'].includes(normalized) ? `attachable-${normalized}` : undefined; + } +} diff --git a/addon/serializers/trailer.js b/addon/serializers/trailer.js new file mode 100644 index 0000000..e8df37b --- /dev/null +++ b/addon/serializers/trailer.js @@ -0,0 +1,18 @@ +import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; + +export default class TrailerSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + vendor: { embedded: 'always' }, + warranty: { embedded: 'always' }, + photo: { embedded: 'always' }, + current_vehicle: { embedded: 'always', serialize: false }, + current_connection: { embedded: 'always', serialize: false }, + connections: { embedded: 'always', serialize: false }, + devices: { embedded: 'always', serialize: false }, + equipments: { embedded: 'always', serialize: false }, + custom_field_values: { embedded: 'always' }, + }; + } +} diff --git a/app/models/asset-connection.js b/app/models/asset-connection.js new file mode 100644 index 0000000..78513f8 --- /dev/null +++ b/app/models/asset-connection.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/asset-connection'; diff --git a/app/models/attachable-trailer.js b/app/models/attachable-trailer.js new file mode 100644 index 0000000..e1ec252 --- /dev/null +++ b/app/models/attachable-trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-trailer'; diff --git a/app/models/maintenance-subject-trailer.js b/app/models/maintenance-subject-trailer.js new file mode 100644 index 0000000..b1f3d42 --- /dev/null +++ b/app/models/maintenance-subject-trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/maintenance-subject-trailer'; diff --git a/app/models/trailer.js b/app/models/trailer.js new file mode 100644 index 0000000..38af3f7 --- /dev/null +++ b/app/models/trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/trailer'; diff --git a/app/serializers/asset-connection.js b/app/serializers/asset-connection.js new file mode 100644 index 0000000..10a5c77 --- /dev/null +++ b/app/serializers/asset-connection.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/serializers/asset-connection'; diff --git a/app/serializers/trailer.js b/app/serializers/trailer.js new file mode 100644 index 0000000..eaccf02 --- /dev/null +++ b/app/serializers/trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/serializers/trailer'; diff --git a/package.json b/package.json index 4e2e79b..704b69a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", "@ember/optional-features": "^2.0.0", + "@ember/string": "^3.1.1", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", "@glimmer/component": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 810f131..48d7546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0 + '@ember/string': + specifier: ^3.1.1 + version: 3.1.1 '@ember/test-helpers': specifier: ^3.2.0 version: 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)) diff --git a/tests/unit/models/asset-connection-test.js b/tests/unit/models/asset-connection-test.js new file mode 100644 index 0000000..1a2ff0f --- /dev/null +++ b/tests/unit/models/asset-connection-test.js @@ -0,0 +1,15 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | asset-connection', function (hooks) { + setupTest(hooks); + + test('it represents an effective-dated towing relationship', function (assert) { + const store = this.owner.lookup('service:store'); + const connection = store.createRecord('asset-connection', { relationship_type: 'towing', active: true, position: 1 }); + + assert.strictEqual(connection.relationship_type, 'towing'); + assert.true(connection.active); + assert.strictEqual(connection.position, 1); + }); +}); diff --git a/tests/unit/models/trailer-test.js b/tests/unit/models/trailer-test.js new file mode 100644 index 0000000..1e01ec7 --- /dev/null +++ b/tests/unit/models/trailer-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | trailer', function (hooks) { + setupTest(hooks); + + test('it exposes first-class identity, capacity, connection, and telemetry state', function (assert) { + const store = this.owner.lookup('service:store'); + const trailer = store.createRecord('trailer', { + name: 'Reefer 12', + year: '2026', + make: 'Utility', + model: '3000R', + location: { type: 'Point', coordinates: [106.9, 47.9] }, + payload_capacity: 20000, + }); + + assert.strictEqual(trailer.asset_class, 'trailer'); + assert.strictEqual(trailer.yearMakeModel, '2026 Utility 3000R'); + assert.strictEqual(trailer.payload_capacity, 20000); + assert.deepEqual(trailer.coordinates, [47.9, 106.9]); + }); +}); diff --git a/tests/unit/serializers/asset-connection-test.js b/tests/unit/serializers/asset-connection-test.js new file mode 100644 index 0000000..ca4064d --- /dev/null +++ b/tests/unit/serializers/asset-connection-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Serializer | asset-connection', function (hooks) { + setupTest(hooks); + + test('it serializes connection metadata', function (assert) { + const store = this.owner.lookup('service:store'); + const serialized = store.createRecord('asset-connection', { relationship_type: 'towing', source: 'manual' }).serialize(); + + assert.strictEqual(serialized.relationship_type, 'towing'); + assert.strictEqual(serialized.source, 'manual'); + }); +}); diff --git a/tests/unit/serializers/device-test.js b/tests/unit/serializers/device-test.js index 5e0ead8..7c41e71 100644 --- a/tests/unit/serializers/device-test.js +++ b/tests/unit/serializers/device-test.js @@ -128,4 +128,29 @@ module('Unit | Serializer | device', function (hooks) { assert.strictEqual(serialized.attachable_uuid, 'vehicle-1'); assert.strictEqual(serialized.attachable_type, 'fleet-ops:vehicle'); }); + + test('it normalizes and serializes trailer attachments', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const normalized = serializer.normalize(store.modelFor('device'), { + uuid: 'device-2', + attachable_uuid: 'trailer-1', + attachable_type: 'fleet-ops:trailer', + attachable: { uuid: 'trailer-1', public_id: 'trailer_1', name: 'Reefer 1' }, + }); + + assert.strictEqual(normalized.data.relationships.attachable.data.type, 'attachable-trailer'); + + const json = {}; + serializer.serializePolymorphicType( + { + attr: () => undefined, + belongsTo: () => ({ modelName: 'attachable-trailer' }), + }, + json, + { key: 'attachable' } + ); + + assert.strictEqual(json.attachable_type, 'fleet-ops:trailer'); + }); }); diff --git a/tests/unit/serializers/equipment-test.js b/tests/unit/serializers/equipment-test.js index 9ae28ec..109536c 100644 --- a/tests/unit/serializers/equipment-test.js +++ b/tests/unit/serializers/equipment-test.js @@ -21,4 +21,29 @@ module('Unit | Serializer | equipment', function (hooks) { assert.ok(serializedRecord); }); + + test('it supports polymorphic vehicle and trailer attachments', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('equipment'); + const normalized = serializer.normalize(store.modelFor('equipment'), { + uuid: 'equipment-1', + equipable_uuid: 'trailer-1', + equipable_type: 'Fleetbase\\FleetOps\\Models\\Trailer', + equipable: { uuid: 'trailer-1', public_id: 'trailer_1', name: 'Flatbed 1' }, + }); + + assert.strictEqual(normalized.data.relationships.equipable.data.type, 'attachable-trailer'); + + const json = {}; + serializer.serializePolymorphicType( + { + attr: () => undefined, + belongsTo: () => ({ modelName: 'attachable-vehicle' }), + }, + json, + { key: 'equipable' } + ); + + assert.strictEqual(json.equipable_type, 'fleet-ops:vehicle'); + }); }); diff --git a/tests/unit/serializers/trailer-test.js b/tests/unit/serializers/trailer-test.js new file mode 100644 index 0000000..41680de --- /dev/null +++ b/tests/unit/serializers/trailer-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Serializer | trailer', function (hooks) { + setupTest(hooks); + + test('it embeds Trailer relationships without writing read-only projections', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + + assert.ok(serializer); + assert.strictEqual(serializer.attrs.vendor.embedded, 'always'); + assert.strictEqual(serializer.attrs.current_vehicle.serialize, false); + assert.strictEqual(serializer.attrs.connections.serialize, false); + assert.strictEqual(serializer.attrs.devices.serialize, false); + }); +}); From 10e5d5a2d4c4ff34435d7dbdc79d7e0bc89160ae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 8 Sep 2026 00:34:31 +0800 Subject: [PATCH 2/3] fix: complete Trailer data contracts for the console - Declare attachment_state and vehicle_id on the Trailer model; the console never received the attachment state because the attribute was missing. - Add displayName, isAttached, isOnline, and formatted last-online/attached-at projections to Trailer, and formatted timing plus duration to AssetConnection. - Add the attachable-driver model so equipment issued to a driver normalizes instead of failing on an unknown model. - Set explicit inverses on the vehicle trailer/equipment relationships and embed the trailer category. - Cover query and single-record envelope normalization, the new projections, and the driver mapping. --- addon/models/asset-connection.js | 26 ++++++++- addon/models/attachable-driver.js | 18 +++++++ addon/models/trailer.js | 39 ++++++++++++++ addon/models/vehicle.js | 6 +-- addon/serializers/trailer.js | 1 + app/models/attachable-driver.js | 1 + tests/unit/models/asset-connection-test.js | 18 +++++++ tests/unit/models/attachable-driver-test.js | 14 +++++ tests/unit/models/trailer-test.js | 23 ++++++++ tests/unit/serializers/equipment-test.js | 14 +++++ tests/unit/serializers/trailer-test.js | 58 +++++++++++++++++++++ 11 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 addon/models/attachable-driver.js create mode 100644 app/models/attachable-driver.js create mode 100644 tests/unit/models/attachable-driver-test.js diff --git a/addon/models/asset-connection.js b/addon/models/asset-connection.js index 9c0ec65..700ab8b 100644 --- a/addon/models/asset-connection.js +++ b/addon/models/asset-connection.js @@ -1,4 +1,6 @@ import Model, { attr, belongsTo } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { format as formatDate, isValid as isValidDate, formatDistanceStrict } from 'date-fns'; export default class AssetConnectionModel extends Model { @attr('string') uuid; @@ -20,6 +22,26 @@ export default class AssetConnectionModel extends Model { @attr('date') created_at; @attr('date') updated_at; - @belongsTo('vehicle', { async: false }) vehicle; - @belongsTo('trailer', { async: false }) trailer; + @belongsTo('vehicle', { async: false, inverse: null }) vehicle; + @belongsTo('trailer', { async: false, inverse: null }) trailer; + + @computed('active', 'disconnected_at') get isActive() { + return this.active === true || (this.active !== false && !this.disconnected_at); + } + + @computed('connected_at') get connectedAt() { + return isValidDate(this.connected_at) ? formatDate(this.connected_at, 'yyyy-MM-dd HH:mm') : null; + } + + @computed('disconnected_at') get disconnectedAt() { + return isValidDate(this.disconnected_at) ? formatDate(this.disconnected_at, 'yyyy-MM-dd HH:mm') : null; + } + + @computed('connected_at', 'disconnected_at') get duration() { + if (!isValidDate(this.connected_at)) { + return null; + } + + return formatDistanceStrict(this.connected_at, isValidDate(this.disconnected_at) ? this.disconnected_at : new Date()); + } } diff --git a/addon/models/attachable-driver.js b/addon/models/attachable-driver.js new file mode 100644 index 0000000..49e66b1 --- /dev/null +++ b/addon/models/attachable-driver.js @@ -0,0 +1,18 @@ +import AttachableModel from './attachable'; +import { attr } from '@ember-data/model'; + +/** + * Concrete polymorphic model for a Driver that equipment is issued to. + * + * Drivers are not telematics attachables, but equipment can be equipped to a driver + * (`fleet-ops:driver`), and the equipment serializer resolves that polymorphic + * relationship through the attachable model family. + */ +export default class AttachableDriverModel extends AttachableModel { + @attr('string') internal_id; + @attr('string') phone; + @attr('string') email; + @attr('string') drivers_license_number; + @attr('string') vehicle_name; + @attr('string') vendor_name; +} diff --git a/addon/models/trailer.js b/addon/models/trailer.js index ea04c75..e78f27a 100644 --- a/addon/models/trailer.js +++ b/addon/models/trailer.js @@ -3,6 +3,7 @@ import { attr, belongsTo, hasMany } from '@ember-data/model'; import { computed, get } from '@ember/object'; import { not } from '@ember/object/computed'; import isValidCoordinates from '@fleetbase/ember-core/utils/is-valid-coordinates'; +import { format as formatDate, isValid as isValidDate, formatDistanceToNow } from 'date-fns'; /** * A first-class towed fleet asset. @@ -47,6 +48,8 @@ export default class TrailerModel extends AssetModel { /** @current operational projections */ @attr('boolean') online; + @attr('string') attachment_state; + @attr('string') vehicle_id; @attr('string') connectivity_status; @attr('string') movement_status; @attr('raw') telematics; @@ -58,6 +61,42 @@ export default class TrailerModel extends AssetModel { @attr('number') equipment_count; @attr('date') last_online_at; + @computed('display_name', 'name', 'yearMakeModel', 'code', 'public_id') get displayName() { + return this.display_name || this.name || this.yearMakeModel || this.code || this.public_id; + } + + @computed('attachment_state') get isAttached() { + return this.attachment_state === 'attached'; + } + + @computed('connectivity_status', 'online') get isOnline() { + return this.connectivity_status === 'online' || this.online === true; + } + + @computed('last_online_at') get lastOnlineAt() { + if (!isValidDate(this.last_online_at)) { + return null; + } + + return formatDate(this.last_online_at, 'yyyy-MM-dd HH:mm'); + } + + @computed('last_online_at') get lastOnlineAgo() { + if (!isValidDate(this.last_online_at)) { + return null; + } + + return formatDistanceToNow(this.last_online_at, { addSuffix: true }); + } + + @computed('attached_at') get attachedAt() { + if (!isValidDate(this.attached_at)) { + return null; + } + + return formatDate(this.attached_at, 'yyyy-MM-dd HH:mm'); + } + @computed('name', 'display_name', 'code', 'plate_number', 'vin', 'serial_number', 'yearMakeModel') get searchString() { return [this.name, this.display_name, this.code, this.plate_number, this.vin, this.serial_number, this.yearMakeModel].filter(Boolean).join(' '); } diff --git a/addon/models/vehicle.js b/addon/models/vehicle.js index 8ce1772..e641478 100644 --- a/addon/models/vehicle.js +++ b/addon/models/vehicle.js @@ -22,9 +22,9 @@ export default class VehicleModel extends Model { @belongsTo('driver', { async: false }) driver; @belongsTo('vendor', { async: false }) vendor; @hasMany('device', { async: false }) devices; - @hasMany('trailer', { async: false }) trailers; - @hasMany('asset-connection', { async: false }) trailer_connections; - @hasMany('equipment', { async: false }) equipments; + @hasMany('trailer', { async: false, inverse: null }) trailers; + @hasMany('asset-connection', { async: false, inverse: null }) trailer_connections; + @hasMany('equipment', { async: false, inverse: null }) equipments; @hasMany('custom-field-value', { async: false }) custom_field_values; /** @attributes */ diff --git a/addon/serializers/trailer.js b/addon/serializers/trailer.js index e8df37b..d2e43ee 100644 --- a/addon/serializers/trailer.js +++ b/addon/serializers/trailer.js @@ -4,6 +4,7 @@ import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; export default class TrailerSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { get attrs() { return { + category: { embedded: 'always' }, vendor: { embedded: 'always' }, warranty: { embedded: 'always' }, photo: { embedded: 'always' }, diff --git a/app/models/attachable-driver.js b/app/models/attachable-driver.js new file mode 100644 index 0000000..6b2fe84 --- /dev/null +++ b/app/models/attachable-driver.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-driver'; diff --git a/tests/unit/models/asset-connection-test.js b/tests/unit/models/asset-connection-test.js index 1a2ff0f..b7a79eb 100644 --- a/tests/unit/models/asset-connection-test.js +++ b/tests/unit/models/asset-connection-test.js @@ -12,4 +12,22 @@ module('Unit | Model | asset-connection', function (hooks) { assert.true(connection.active); assert.strictEqual(connection.position, 1); }); + + test('it formats connection timing for the console', function (assert) { + const store = this.owner.lookup('service:store'); + const active = store.createRecord('asset-connection', { connected_at: new Date('2026-09-01T08:00:00Z') }); + const ended = store.createRecord('asset-connection', { + active: false, + connected_at: new Date('2026-09-01T08:00:00Z'), + disconnected_at: new Date('2026-09-01T10:00:00Z'), + }); + + assert.true(active.isActive); + assert.ok(active.connectedAt.startsWith('2026-09-01')); + assert.strictEqual(active.disconnectedAt, null); + assert.ok(active.duration); + assert.false(ended.isActive); + assert.strictEqual(ended.duration, '2 hours'); + assert.strictEqual(store.createRecord('asset-connection').duration, null); + }); }); diff --git a/tests/unit/models/attachable-driver-test.js b/tests/unit/models/attachable-driver-test.js new file mode 100644 index 0000000..37889e0 --- /dev/null +++ b/tests/unit/models/attachable-driver-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | attachable-driver', function (hooks) { + setupTest(hooks); + + test('it resolves equipment issued to a driver through the attachable family', function (assert) { + const store = this.owner.lookup('service:store'); + const driver = store.createRecord('attachable-driver', { name: 'Dana Driver', internal_id: 'DRV-1' }); + + assert.strictEqual(driver.displayName, 'Dana Driver'); + assert.strictEqual(driver.internal_id, 'DRV-1'); + }); +}); diff --git a/tests/unit/models/trailer-test.js b/tests/unit/models/trailer-test.js index 1e01ec7..7501ebb 100644 --- a/tests/unit/models/trailer-test.js +++ b/tests/unit/models/trailer-test.js @@ -20,4 +20,27 @@ module('Unit | Model | trailer', function (hooks) { assert.strictEqual(trailer.payload_capacity, 20000); assert.deepEqual(trailer.coordinates, [47.9, 106.9]); }); + + test('it derives display, attachment, and connectivity projections', function (assert) { + const store = this.owner.lookup('service:store'); + const trailer = store.createRecord('trailer', { + public_id: 'trailer_one', + attachment_state: 'attached', + connectivity_status: 'recently_offline', + last_online_at: new Date('2026-09-01T10:30:00Z'), + attached_at: new Date('2026-08-30T08:00:00Z'), + }); + + assert.strictEqual(trailer.displayName, 'trailer_one', 'falls back to the public id when no name is set'); + assert.true(trailer.isAttached); + assert.false(trailer.isOnline); + assert.ok(trailer.lastOnlineAt.startsWith('2026-09-01')); + assert.ok(trailer.attachedAt.startsWith('2026-08-30')); + assert.ok(trailer.lastOnlineAgo); + + trailer.setProperties({ name: 'Reefer 12', connectivity_status: 'online', last_online_at: null }); + assert.strictEqual(trailer.displayName, 'Reefer 12'); + assert.true(trailer.isOnline); + assert.strictEqual(trailer.lastOnlineAt, null); + }); }); diff --git a/tests/unit/serializers/equipment-test.js b/tests/unit/serializers/equipment-test.js index 109536c..b6e8cea 100644 --- a/tests/unit/serializers/equipment-test.js +++ b/tests/unit/serializers/equipment-test.js @@ -46,4 +46,18 @@ module('Unit | Serializer | equipment', function (hooks) { assert.strictEqual(json.equipable_type, 'fleet-ops:vehicle'); }); + + test('it resolves equipment issued to a driver through the attachable-driver model', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('equipment'); + const normalized = serializer.normalize(store.modelFor('equipment'), { + uuid: 'equipment-2', + equipable_uuid: 'driver-1', + equipable_type: 'fleet-ops:driver', + equipable: { uuid: 'driver-1', public_id: 'driver_1', name: 'Dana Driver' }, + }); + + assert.strictEqual(normalized.data.relationships.equipable.data.type, 'attachable-driver'); + assert.ok(store.modelFor('attachable-driver'), 'the attachable-driver model exists for the store'); + }); }); diff --git a/tests/unit/serializers/trailer-test.js b/tests/unit/serializers/trailer-test.js index 41680de..cbc350d 100644 --- a/tests/unit/serializers/trailer-test.js +++ b/tests/unit/serializers/trailer-test.js @@ -10,8 +10,66 @@ module('Unit | Serializer | trailer', function (hooks) { assert.ok(serializer); assert.strictEqual(serializer.attrs.vendor.embedded, 'always'); + assert.strictEqual(serializer.attrs.category.embedded, 'always'); assert.strictEqual(serializer.attrs.current_vehicle.serialize, false); assert.strictEqual(serializer.attrs.connections.serialize, false); assert.strictEqual(serializer.attrs.devices.serialize, false); + assert.strictEqual(serializer.attrs.equipments.serialize, false); + }); + + test('it normalizes a `trailers` collection envelope into an array of Trailer records', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + const payload = { + trailers: [ + { id: 'trailer-1', uuid: 'trailer-1', public_id: 'trailer_one', name: 'Reefer 12', type: 'reefer', status: 'available', attachment_state: 'detached' }, + { id: 'trailer-2', uuid: 'trailer-2', public_id: 'trailer_two', name: 'Flatbed 3', type: 'flatbed', status: 'in_use', attachment_state: 'attached' }, + ], + meta: { total: 2, current_page: 1, last_page: 1 }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), payload, null, 'query'); + + assert.ok(Array.isArray(normalized.data), 'query responses normalize to an array'); + assert.strictEqual(normalized.data.length, 2); + assert.deepEqual( + normalized.data.map((resource) => resource.type), + ['trailer', 'trailer'] + ); + assert.strictEqual(normalized.data[0].attributes.attachment_state, 'detached'); + assert.deepEqual(normalized.meta, { total: 2, current_page: 1, last_page: 1 }); + }); + + test('it normalizes an empty `trailers` collection to an empty array', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), { trailers: [], meta: { total: 0 } }, null, 'query'); + + assert.deepEqual(normalized.data, []); + }); + + test('it normalizes a single `trailer` record envelope with embedded connection state', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + const payload = { + trailer: { + id: 'trailer-1', + uuid: 'trailer-1', + public_id: 'trailer_one', + name: 'Reefer 12', + current_vehicle: { id: 'vehicle-1', uuid: 'vehicle-1', public_id: 'vehicle_one', name: 'Truck 1' }, + current_connection: { id: 'connection-1', uuid: 'connection-1', public_id: 'connection_one', relationship_type: 'towing', position: 1, active: true }, + connections: [{ id: 'connection-1', uuid: 'connection-1', public_id: 'connection_one', relationship_type: 'towing', position: 1, active: true }], + }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), payload, 'trailer-1', 'findRecord'); + + assert.strictEqual(normalized.data.type, 'trailer'); + assert.strictEqual(normalized.data.relationships.current_vehicle.data.id, 'vehicle-1'); + assert.strictEqual(normalized.data.relationships.current_connection.data.type, 'asset-connection'); + assert.strictEqual(normalized.data.relationships.connections.data.length, 1); + assert.ok(normalized.included.some((resource) => resource.type === 'vehicle' && resource.id === 'vehicle-1')); }); }); From 9fac46a771f1ba0ee1519756e3299973edc63863 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 8 Sep 2026 10:42:50 +0800 Subject: [PATCH 3/3] feat(vehicle): embed current trailers on the vehicle serializer The live map feed now sends each vehicle's currently coupled trailers alongside its devices so map popovers can list both. Declare the relationship as embedded so normalization pushes the trailer records instead of treating the objects as ids, and cover it with a normalizeResponse test. --- addon/serializers/vehicle.js | 1 + tests/unit/serializers/vehicle-test.js | 28 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/addon/serializers/vehicle.js b/addon/serializers/vehicle.js index 3c23cf8..9d501c7 100644 --- a/addon/serializers/vehicle.js +++ b/addon/serializers/vehicle.js @@ -12,6 +12,7 @@ export default class VehicleSerializer extends ApplicationSerializer.extend(Embe driver: { embedded: 'always' }, vendor: { embedded: 'always' }, devices: { embedded: 'always' }, + trailers: { embedded: 'always' }, custom_field_values: { embedded: 'always' }, }; } diff --git a/tests/unit/serializers/vehicle-test.js b/tests/unit/serializers/vehicle-test.js index f207c7a..83ef8ed 100644 --- a/tests/unit/serializers/vehicle-test.js +++ b/tests/unit/serializers/vehicle-test.js @@ -4,7 +4,6 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Serializer | vehicle', function (hooks) { setupTest(hooks); - // Replace this with your real tests. test('it exists', function (assert) { let store = this.owner.lookup('service:store'); let serializer = store.serializerFor('vehicle'); @@ -20,4 +19,31 @@ module('Unit | Serializer | vehicle', function (hooks) { assert.ok(serializedRecord); }); + + test('it embeds the devices and current trailers the live feed sends with each vehicle', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('vehicle'); + + assert.strictEqual(serializer.attrs.devices.embedded, 'always'); + assert.strictEqual(serializer.attrs.trailers.embedded, 'always'); + + const payload = { + vehicle: { + id: 'vehicle-1', + uuid: 'vehicle-1', + public_id: 'vehicle_one', + name: 'Truck 1', + devices: [{ id: 'device-1', uuid: 'device-1', public_id: 'device_one', name: 'Tracker', online: true }], + trailers: [{ id: 'trailer-1', uuid: 'trailer-1', public_id: 'trailer_one', name: 'Reefer 12', type: 'reefer', attachment_state: 'attached', online: false }], + }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('vehicle'), payload, 'vehicle-1', 'findRecord'); + const includedTypes = normalized.included.map((resource) => resource.type); + + assert.deepEqual(normalized.data.relationships.trailers.data, [{ id: 'trailer-1', type: 'trailer' }]); + assert.deepEqual(normalized.data.relationships.devices.data, [{ id: 'device-1', type: 'device' }]); + assert.ok(includedTypes.includes('trailer'), 'embedded trailers are pushed alongside the vehicle'); + assert.ok(includedTypes.includes('device'), 'embedded devices are pushed alongside the vehicle'); + }); });