Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
57d7599
chore: add AGENTS.md (phase 1.6)
Apr 6, 2026
64adac3
docs: clarify boost gate (host-cloned, needs real tty)
Apr 6, 2026
b15ea08
feat(parcelpath): add ParcelPathServiceType enum
TLemmAI Apr 8, 2026
7e0c819
feat(tracking): add carrier_tracking_number column
TLemmAI Apr 8, 2026
3c0e5b4
feat(parcelpath): register ParcelPath in IntegratedVendors
TLemmAI Apr 8, 2026
189dfe8
feat(parcelpath): scaffold bridge class with HTTP client
TLemmAI Apr 9, 2026
e8f6db1
feat(parcelpath): rating via POST /v1/rates
TLemmAI Apr 9, 2026
5870f32
feat(parcelpath): label purchase via POST /v1/labels
TLemmAI Apr 9, 2026
52d6aac
feat(parcelpath): tracking status + void shipment
TLemmAI Apr 9, 2026
6494c48
feat(labels): serve carrier labels from File before internal fallback
TLemmAI Apr 9, 2026
75eb797
feat(parcelpath): tracking poll job + terminal status helper
TLemmAI Apr 9, 2026
f28623d
feat(ui): carrier onboarding panel for ParcelPath / direct UPS/USPS
TLemmAI Apr 9, 2026
ae04755
feat(ui): rate comparison enriched for integrated vendor quotes
TLemmAI Apr 9, 2026
f0cce79
chore: ignore Pest workaround `vendor` symlink
TLemmAI Apr 9, 2026
9602e70
feat(ups): add UPSServiceType enum
TLemmAI Apr 9, 2026
3ba788c
feat(ups): add UPSOAuthClient with injectable cache
TLemmAI Apr 9, 2026
e40354a
feat(broker): shipper_client_uuid on integrated_vendors
TLemmAI Apr 9, 2026
1f804f7
feat(ups): Rate Shop rating bridge with pure/impure split
TLemmAI Apr 9, 2026
d521ad7
feat(ups): createShipment + void with pure/impure split
TLemmAI Apr 9, 2026
8c7d495
feat(ups): register in IntegratedVendors (partial Task 17 — UPS only)
TLemmAI Apr 9, 2026
c1a8e84
feat(usps): USPSServiceType + USPS bridge (v3 direct)
TLemmAI Apr 9, 2026
c7a5357
feat(usps): register in IntegratedVendors (completes Task 17)
TLemmAI Apr 9, 2026
e96465a
feat(service-quotes): broker auto-resolve by shipper_client_uuid
TLemmAI Apr 9, 2026
d8ff279
feat(tracking): PollUPS + PollUSPS tracking jobs + UPS event mapping
TLemmAI Apr 10, 2026
27a3a47
feat(ui): shipper client selector on IntegratedVendor form
TLemmAI Apr 10, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
composer.lock
/server_vendor
/server/vendor/
# Local Pest workaround: a `vendor -> server_vendor` symlink at the
# project root, created on each one-off pest run because Pest's bin
# script hardcodes the literal `vendor/` path while fleetops uses a
# non-standard composer vendor-dir of `server_vendor`. Not real source.
/vendor
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
Expand Down
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# AGENTS.md — fleetops

## Repo purpose
The FleetOps TMS extension. Dual-structure:
- `server/` — Laravel package (`fleetbase/fleetops-api`)
- `addon/` — Ember addon (`@fleetbase/fleetops-engine`)

Both halves are linked into the host (`fleetbase/api` and `fleetbase/console` respectively) and ship together.

## What this repo owns
- `server/src/Models/` — Driver, Vehicle, Fleet, Order, Place, Zone, ServiceRate, etc.
- `server/src/Http/Controllers/` — FleetOps REST endpoints
- `server/database/migrations/` — FleetOps tables
- `server/routes/api.php` — route definitions registered under the `fleetops` namespace
- `addon/app/` — Ember engine source: routes, components, services
- `addon/extension.json` — extension registration metadata

## What this repo must not modify
- `core-api` source. If you need a new helper, propose it as a separate task in `core-api`.
- Other extensions (`storefront`, `pallet`, `ledger`).
- The host console's router or top-level layout.

## Framework conventions
- Server: Laravel 10+, PSR-4 under `Fleetbase\\FleetOps\\`
- Addon: Ember Engine via `ember-engines`, registered with `UniverseService` from `ember-core`
- Migrations: append-only, prefix with date

## Test / build commands
- Server: `vendor/bin/phpunit` inside `server/`
- Addon: `cd addon && pnpm test` (rarely needed; the host console picks up changes via hot reload)
- After server changes: `docker compose exec application php artisan migrate` and `php artisan route:list | grep fleetops`

## Known sharp edges
- **FleetOps is already auto-loaded** in this workspace via the `fleetbase/packages/fleetops` submodule. Linking the top-level `fleetops/` clone is only needed if you want to **edit** it. Until then, the bundled image's copy is what's running.
- The `addon/` and `server/` halves are versioned together — changing only one risks runtime drift.
- Live map requires `GOOGLE_MAPS_API_KEY` to function.

## Read first
- `~/fleetbase-project/docs/project-description.md`
- `~/fleetbase-project/docs/repo-map.md`
- `~/fleetbase-project/docs/ai-rules-laravel.md` (for `server/`)
- `~/fleetbase-project/docs/ai-rules-ember.md` (for `addon/`)
- `~/fleetbase-project/docs/ai-rules-workspace.md`
- `~/fleetbase-project/docs/extension-contracts.md`

## Boost gate
`fleetops/server` IS host-cloned, so Boost outputs would land in a place future agents can read. Before first edit in `server/`: `composer require laravel/boost --dev && php artisan boost:install` from a **real terminal** (the installer is interactive and crashes on `docker compose exec -T`). Then commit.
61 changes: 61 additions & 0 deletions addon/components/carrier-onboarding-panel.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<div class="carrier-onboarding-panel space-y-4" ...attributes>
<div class="text-base font-semibold text-black dark:text-white">
{{t "carrier-onboarding.heading"}}
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
{{t "carrier-onboarding.description"}}
</p>

{{!-- Recommended: ParcelPath --}}
<div class="rounded-md border border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20 p-4" data-test-onboarding-card="parcelpath">
<div class="flex items-start justify-between">
<div class="flex items-center">
<img src="/images/integrated-vendors/parcelpath.png" alt="ParcelPath" class="w-10 h-10 mr-3" />
<div>
<div class="flex items-center">
<h4 class="text-base font-semibold text-black dark:text-white">{{t "carrier-onboarding.parcelpath.title"}}</h4>
<Badge @hideStatusDot={{true}} @status="success" class="ml-2">{{t "carrier-onboarding.recommended"}}</Badge>
</div>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1">
{{t "carrier-onboarding.parcelpath.description"}}
</p>
</div>
</div>
<Button
@type="primary"
@text={{t "carrier-onboarding.parcelpath.cta"}}
@onClick={{fn this.connectProvider "parcelpath"}}
data-test-connect="parcelpath"
/>
</div>
</div>

{{!-- Alternative: direct carrier accounts --}}
<details class="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4" data-test-onboarding-card="direct">
<summary class="text-sm font-medium text-black dark:text-white cursor-pointer">
{{t "carrier-onboarding.direct.summary"}}
</summary>
<p class="text-xs text-gray-600 dark:text-gray-300 mt-2">
{{t "carrier-onboarding.direct.description"}}
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
<div class="flex items-center justify-between rounded border border-gray-200 dark:border-gray-700 p-3">
<div class="flex items-center">
<img src="/images/integrated-vendors/ups.png" alt="UPS" class="w-8 h-8 mr-3" />
<span class="text-sm">{{t "carrier-onboarding.direct.ups"}}</span>
</div>
<Button @text={{t "carrier-onboarding.direct.connect"}} @onClick={{fn this.connectProvider "ups"}} data-test-connect="ups" />
</div>
<div class="flex items-center justify-between rounded border border-gray-200 dark:border-gray-700 p-3">
<div class="flex items-center">
<img src="/images/integrated-vendors/usps.png" alt="USPS" class="w-8 h-8 mr-3" />
<span class="text-sm">{{t "carrier-onboarding.direct.usps"}}</span>
</div>
<Button @text={{t "carrier-onboarding.direct.connect"}} @onClick={{fn this.connectProvider "usps"}} data-test-connect="usps" />
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
{{t "carrier-onboarding.direct.hybrid-note"}}
</p>
</details>
</div>
37 changes: 37 additions & 0 deletions addon/components/carrier-onboarding-panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';

/**
* CarrierOnboardingPanel
*
* Onboarding surface that nudges new operators toward ParcelPath as the
* default ParcelPath/UPS/USPS integration path while still exposing the
* direct UPS and USPS bridges for shippers with their own carrier
* contracts. Uses the existing IntegratedVendor create flow — clicking
* a "Connect" button hands the chosen provider to
* `integratedVendorActions.create`, which routes to the same form the
* Settings → Integrated Vendors page already renders.
*/
export default class CarrierOnboardingPanelComponent extends Component {
@service integratedVendorActions;
@service router;

@action
connectProvider(providerCode) {
// Prefer the existing IntegratedVendor create action if it exposes
// a `create` modal/form (mirrors how the settings page wires up
// new vendors). Fall back to a route transition with a query
// param so the IntegratedVendor form can preselect the provider.
if (
this.integratedVendorActions &&
typeof this.integratedVendorActions.create === 'function'
) {
return this.integratedVendorActions.create({ provider: providerCode });
}
return this.router.transitionTo(
'console.fleet-ops.management.integrated-vendors.new',
{ queryParams: { provider: providerCode } }
);
}
}
20 changes: 20 additions & 0 deletions addon/components/integrated-vendor/form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@
</InputGroup>
</div>
</ContentPanel>
<ContentPanel @title={{t "integrated-vendor.fields.shipper-client"}} @open={{true}} @wrapperClass="bordered-top">
<div class="space-y-2">
<InputGroup @name={{t "integrated-vendor.fields.shipper-client-label"}} @helpText={{t "integrated-vendor.fields.shipper-client-help-text"}}>
<ModelSelect
@modelName="vendor"
@selectedModel={{this.selectedShipperClient}}
@placeholder={{t "integrated-vendor.fields.shipper-client-placeholder"}}
@triggerClass="form-select form-input"
@infiniteScroll={{false}}
@renderInPlace={{true}}
@allowClear={{true}}
@onChange={{this.setShipperClient}}
@disabled={{cannot-write @resource}}
as |model|
>
{{model.name}}
</ModelSelect>
</InputGroup>
</div>
</ContentPanel>
<ContentPanel @title={{t "integrated-vendor.fields.advanced-options"}} @open={{false}} @wrapperClass="bordered-top">
<div class="space-y-2">
<InputGroup
Expand Down
52 changes: 52 additions & 0 deletions addon/components/integrated-vendor/form.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';

export default class IntegratedVendorFormComponent extends Component {
@service store;
@tracked showAdvancedOptions = false;

/**
* The currently selected shipper client Vendor for broker-scoped
* credential records. Tracked so the <ModelSelect> reflects the
* current state. Loaded from the store on first render when
* shipper_client_uuid is already set on the resource (edit flow).
*/
@tracked selectedShipperClient = null;

constructor() {
super(...arguments);
this._loadExistingShipperClient();
}

/**
* If the resource already has a shipper_client_uuid (e.g. editing
* an existing IntegratedVendor), resolve the Vendor model from the
* store so the <ModelSelect> renders the name rather than showing
* "empty".
*/
async _loadExistingShipperClient() {
const uuid = this.args.resource?.shipper_client_uuid;
if (!uuid) {
return;
}
try {
// peekRecord first (might already be in the store from a previous load)
let vendor = this.store.peekRecord('vendor', uuid);
if (!vendor) {
vendor = await this.store.findRecord('vendor', uuid);
}
this.selectedShipperClient = vendor;
} catch {
// Vendor may have been deleted or is inaccessible — leave the
// selector empty; the raw UUID on the resource is preserved.
}
}

@action toggleAdvancedOptions() {
this.showAdvancedOptions = !this.showAdvancedOptions;
}

/**
* Called by the <ModelSelect> when the user picks a Vendor or clears
* the selection. Sets the raw UUID on the resource (which the API
* serializer will persist) and updates the tracked property so the
* dropdown reflects the change.
*/
@action setShipperClient(vendor) {
this.selectedShipperClient = vendor;
if (this.args.resource) {
this.args.resource.shipper_client_uuid = vendor?.id ?? null;
}
}
}
31 changes: 27 additions & 4 deletions addon/components/order/form/service-rate.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
@icon="refresh"
@text={{t "order.fields.refresh-button"}}
@isLoading={{this.getServiceQuotes.isRunning}}
@disabled={{not this.selectedRate}}
@disabled={{and (not this.selectedRate) (not this.isIntegratedVendorFacilitator)}}
@onClick={{perform this.getServiceQuotes this.selectedRate}}
/>
</div>
Expand All @@ -48,7 +48,7 @@
<InfoBlock class="my-3" @text={{t "order.fields.service-quote-info"}} />
<div class="radio-group-condensed -space-y-px">
{{#each this.serviceQuotes as |serviceQuote|}}
<div class="radio-group-item shadow-sm flex-col pl-0i pr-0i {{if (eq @resource.service_quote_uuid serviceQuote.uuid) 'is-checked'}}">
<div class="radio-group-item shadow-sm flex-col pl-0i pr-0i {{if (eq @resource.service_quote_uuid serviceQuote.uuid) 'is-checked'}}" data-test-service-quote={{serviceQuote.uuid}}>
<div class="flex flex-row items-center mb-2.5 px-4">
<RadioButton
@radioClass="focus:ring-blue-500 h-6 w-6 text-blue-500"
Expand All @@ -58,9 +58,32 @@
@name="serviceQuote"
@changed={{fn (mut @resource.service_quote_uuid)}}
/>
<label for={{serviceQuote.uuid}} class="ml-3 flex-1">{{serviceQuote.public_id}}</label>
{{#if serviceQuote.meta.carrier}}
<img
src="/images/integrated-vendors/{{lowercase serviceQuote.meta.carrier}}.png"
alt={{serviceQuote.meta.carrier}}
class="ml-3 w-6 h-6"
data-test-carrier-logo={{serviceQuote.meta.carrier}}
/>
<label for={{serviceQuote.uuid}} class="ml-2 flex-1">
<div class="text-sm font-semibold text-black dark:text-white" data-test-quote-service-token>
{{or serviceQuote.meta.service_token serviceQuote.public_id}}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{serviceQuote.meta.carrier}}
{{#if serviceQuote.meta.estimated_days}}
· <span data-test-quote-eta>{{t "order.fields.estimated-days" days=serviceQuote.meta.estimated_days}}</span>
{{/if}}
{{#if this.isIntegratedVendorFacilitator}}
· <span data-test-quote-source>{{t "order.fields.via-facilitator" facilitator=@resource.facilitator.name}}</span>
{{/if}}
</div>
</label>
{{else}}
<label for={{serviceQuote.uuid}} class="ml-3 flex-1">{{serviceQuote.public_id}}</label>
{{/if}}
<Badge @hideStatusDot={{true}} @status="info">
{{serviceQuote.request_id}}
{{or serviceQuote.meta.carrier serviceQuote.request_id}}
</Badge>
</div>
<div class="next-table-wrapper no-scroll h-auto table-fluid rounded-none">
Expand Down
20 changes: 20 additions & 0 deletions addon/components/order/form/service-rate.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,29 @@ export default class OrderFormServiceRateComponent extends Component {
return this.args.resource?.order_config && this.args.resource?.payloadCoordinates?.length >= 2;
}

/**
* True when the order's facilitator is an IntegratedVendor (e.g.
* ParcelPath / UPS Direct / USPS Direct). For these, the bridge layer
* resolves rates server-side from the vendor's API instead of from
* locally configured ServiceRate records, so the rate-selector
* dropdown is hidden and quotes load directly when the toggle flips
* on.
*/
get isIntegratedVendorFacilitator() {
return this.args.resource?.facilitator?.get?.('isIntegratedVendor') ?? false;
}

@task *queryServiceRates(toggled) {
this.args.resource.servicable = toggled;
if (!toggled) return;

// Integrated-vendor path: skip the local ServiceRate query and fetch
// quotes straight from the bound vendor (ParcelPath / UPS / USPS).
if (this.isIntegratedVendorFacilitator) {
yield this.getServiceQuotes.perform(null);
return;
}

this.serviceRates = yield this.serviceRateActions.queryServiceRatesForOrder.perform(this.args.resource);
}

Expand Down
1 change: 1 addition & 0 deletions app/components/carrier-onboarding-panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@fleetbase/fleetops-engine/components/carrier-onboarding-panel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('tracking_numbers', function (Blueprint $table) {
$table->string('carrier_tracking_number', 100)->nullable()->after('tracking_number');
$table->index('carrier_tracking_number');
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('tracking_numbers', function (Blueprint $table) {
$table->dropIndex(['carrier_tracking_number']);
$table->dropColumn('carrier_tracking_number');
});
}
};
Loading