Skip to content

Commit f0f0f6c

Browse files
committed
feat: batch shipping workflow (rate + purchase endpoints + UI)
Adds batch shipping endpoints and a minimal Ember component for bulk rating and label purchasing across ParcelPath / UPS / USPS. ## API endpoints POST /v1/batch-shipments/rates - Body: { shipments: [{ row_id?, pickup, dropoff, parcels: [{l,w,h,weight}], facilitator?, service_type? }] } - Response: { results: [{ row_id, status: ok|error, error?, rates: [ServiceQuote...] }] } POST /v1/batch-shipments/purchase - Body: { purchases: [{ row_id?, service_quote_uuid, order_uuid? }] } - Response: { results: [{ row_id, status: ok|error, error?, tracking_number?, label_file_uuid?, order_uuid? }] } Both endpoints: - Accept caller-provided row_id (or auto-generate row_N) - Return row_id in every response row - Per-row error isolation (one row failing never aborts the batch) - Sequential processing in chunks of 5 - Reuse existing bridge methods (getQuoteFromPayload, createOrderFromServiceQuote) — no new carrier logic ## Idempotency (purchase endpoint) Before buying a label, two checks: 1. PurchaseRate already exists for the ServiceQuote → return existing tracking_number + label_file_uuid 2. Order.meta.integrated_vendor_order.tracking_number is already set → return existing label File Both paths short-circuit the bridge call and return the prior result. ## Pure helper: BatchShipmentValidator - validateRatesInput(array): [validRows[], errors[]] - validatePurchaseInput(array): [validRows[], errors[]] - resolveRowId(row, index): string 15 Pest tests covering: row_id resolution (caller-provided, auto- generated, empty string), rates validation (valid row, missing pickup/dropoff/parcels, empty parcels, mixed valid+invalid, auto row_id, optional field defaults), purchase validation (valid row, missing service_quote_uuid, order_uuid default, auto row_id). ## Ember component: <BatchShipping /> Minimal 5-step flow: 1. Upload — CSV file input (row_id, pickup, dropoff, length, width, height, weight, facilitator) 2. Preview — table of parsed rows 3. Rate — calls POST /batch-shipments/rates, populates rates 4. Select — per-row rate dropdown (default: first/cheapest), or error badge for failed rows 5. Purchase — calls POST /batch-shipments/purchase, shows tracking numbers + Download PDF links Component uses @Tracked step state machine (upload → preview → rated → purchasing → done) with a reset action to start over. Standard addon/app component pair for the re-export shim. ## Route registration Two new routes in the API v1 group: - POST /v1/batch-shipments/rates → BatchShipmentController@rates - POST /v1/batch-shipments/purchase → BatchShipmentController@purchase ## Translations 27 new keys under batch-shipping.* covering all step titles, column headers, button labels, and status badges. ## Stateless design No Batch entity or model. Each row maps to existing ServiceQuote + Order + File records. The caller tracks batch membership client-side. The server processes each row independently. Tests (Pest): 15 new validator tests. Full suite: 285 passed (610 assertions), 0 failures, 0 regressions.
1 parent 2b67688 commit f0f0f6c

8 files changed

Lines changed: 895 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<div class="batch-shipping-panel" ...attributes>
2+
{{!-- Step: Upload --}}
3+
{{#if (eq this.step "upload")}}
4+
<ContentPanel @title={{t "batch-shipping.title"}} @open={{true}} @wrapperClass="bordered-top">
5+
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">{{t "batch-shipping.upload-description"}}</p>
6+
<input
7+
type="file"
8+
accept=".csv"
9+
class="form-input text-sm"
10+
{{on "change" this.handleFileUpload}}
11+
data-test-csv-upload
12+
/>
13+
</ContentPanel>
14+
{{/if}}
15+
16+
{{!-- Step: Preview --}}
17+
{{#if (eq this.step "preview")}}
18+
<ContentPanel @title={{t "batch-shipping.preview-title"}} @open={{true}} @wrapperClass="bordered-top">
19+
<div class="next-table-wrapper no-scroll mb-4">
20+
<table class="table table-fixed w-full">
21+
<thead>
22+
<tr>
23+
<th>{{t "batch-shipping.col-row-id"}}</th>
24+
<th>{{t "batch-shipping.col-pickup"}}</th>
25+
<th>{{t "batch-shipping.col-dropoff"}}</th>
26+
<th>{{t "batch-shipping.col-weight"}}</th>
27+
<th>{{t "batch-shipping.col-facilitator"}}</th>
28+
</tr>
29+
</thead>
30+
<tbody>
31+
{{#each this.rows as |row|}}
32+
<tr data-test-preview-row={{row.row_id}}>
33+
<td>{{row.row_id}}</td>
34+
<td>{{row.pickup}}</td>
35+
<td>{{row.dropoff}}</td>
36+
<td>{{row.parcels.firstObject.weight}} lb</td>
37+
<td>{{or row.facilitator "auto"}}</td>
38+
</tr>
39+
{{/each}}
40+
</tbody>
41+
</table>
42+
</div>
43+
<div class="flex justify-between">
44+
<Button @text={{t "batch-shipping.back"}} @onClick={{this.reset}} />
45+
<Button @type="primary" @text={{t "batch-shipping.rate-all"}} @isLoading={{this.isLoading}} @onClick={{this.rateAll}} data-test-rate-all />
46+
</div>
47+
</ContentPanel>
48+
{{/if}}
49+
50+
{{!-- Step: Rated --}}
51+
{{#if (eq this.step "rated")}}
52+
<ContentPanel @title={{t "batch-shipping.rated-title"}} @open={{true}} @wrapperClass="bordered-top">
53+
<div class="next-table-wrapper no-scroll mb-4">
54+
<table class="table table-fixed w-full">
55+
<thead>
56+
<tr>
57+
<th>{{t "batch-shipping.col-row-id"}}</th>
58+
<th>{{t "batch-shipping.col-pickup"}}</th>
59+
<th>{{t "batch-shipping.col-dropoff"}}</th>
60+
<th>{{t "batch-shipping.col-rate"}}</th>
61+
<th>{{t "batch-shipping.col-status"}}</th>
62+
</tr>
63+
</thead>
64+
<tbody>
65+
{{#each this.rows as |row|}}
66+
<tr data-test-rated-row={{row.row_id}}>
67+
<td>{{row.row_id}}</td>
68+
<td class="truncate">{{row.pickup}}</td>
69+
<td class="truncate">{{row.dropoff}}</td>
70+
<td>
71+
{{#if row._rateError}}
72+
<span class="text-red-500 text-xs">{{row._rateError}}</span>
73+
{{else if row._rates.length}}
74+
<select
75+
class="form-select form-input text-xs w-full"
76+
{{on "change" (fn this.selectQuote row)}}
77+
data-test-rate-select={{row.row_id}}
78+
>
79+
{{#each row._rates as |rate|}}
80+
<option value={{rate.uuid}} selected={{eq rate.uuid row._selectedQuoteUuid}}>
81+
{{or rate.meta.carrier ""}} {{or rate.meta.service_token rate.public_id}}{{format-currency rate.amount rate.currency}}
82+
</option>
83+
{{/each}}
84+
</select>
85+
{{else}}
86+
<span class="text-gray-400 text-xs">{{t "batch-shipping.no-rates"}}</span>
87+
{{/if}}
88+
</td>
89+
<td>
90+
{{#if row._rateError}}
91+
<Badge @status="danger" @hideStatusDot={{true}}>{{t "common.error"}}</Badge>
92+
{{else if row._rates.length}}
93+
<Badge @status="success" @hideStatusDot={{true}}>{{t "batch-shipping.rated"}}</Badge>
94+
{{/if}}
95+
</td>
96+
</tr>
97+
{{/each}}
98+
</tbody>
99+
</table>
100+
</div>
101+
<div class="flex justify-between">
102+
<Button @text={{t "batch-shipping.back"}} @onClick={{this.reset}} />
103+
<Button @type="primary" @text={{t "batch-shipping.purchase-all"}} @isLoading={{this.isLoading}} @onClick={{this.purchaseAll}} data-test-purchase-all />
104+
</div>
105+
</ContentPanel>
106+
{{/if}}
107+
108+
{{!-- Step: Purchasing --}}
109+
{{#if (eq this.step "purchasing")}}
110+
<ContentPanel @title={{t "batch-shipping.purchasing-title"}} @open={{true}} @wrapperClass="bordered-top">
111+
<div class="flex items-center justify-center py-10">
112+
<Spinner class="text-sm dark:text-gray-100 flex flex-row items-center" @iconClass="mr-2" @loadingMessage={{t "batch-shipping.purchasing-message"}} />
113+
</div>
114+
</ContentPanel>
115+
{{/if}}
116+
117+
{{!-- Step: Done --}}
118+
{{#if (eq this.step "done")}}
119+
<ContentPanel @title={{t "batch-shipping.done-title"}} @open={{true}} @wrapperClass="bordered-top">
120+
<div class="next-table-wrapper no-scroll mb-4">
121+
<table class="table table-fixed w-full">
122+
<thead>
123+
<tr>
124+
<th>{{t "batch-shipping.col-row-id"}}</th>
125+
<th>{{t "batch-shipping.col-tracking"}}</th>
126+
<th>{{t "batch-shipping.col-label"}}</th>
127+
<th>{{t "batch-shipping.col-status"}}</th>
128+
</tr>
129+
</thead>
130+
<tbody>
131+
{{#each this.rows as |row|}}
132+
<tr data-test-done-row={{row.row_id}}>
133+
<td>{{row.row_id}}</td>
134+
<td>{{or row._purchaseResult.tracking_number ""}}</td>
135+
<td>
136+
{{#if row._purchaseResult.label_file_uuid}}
137+
<a href="/v1/files/{{row._purchaseResult.label_file_uuid}}/download" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">
138+
{{t "batch-shipping.download-label"}}
139+
</a>
140+
{{else}}
141+
142+
{{/if}}
143+
</td>
144+
<td>
145+
{{#if (eq row._purchaseResult.status "ok")}}
146+
<Badge @status="success" @hideStatusDot={{true}}>{{t "batch-shipping.purchased"}}</Badge>
147+
{{else if (eq row._purchaseResult.status "error")}}
148+
<Badge @status="danger" @hideStatusDot={{true}}>{{row._purchaseResult.error}}</Badge>
149+
{{else}}
150+
<Badge @status="default" @hideStatusDot={{true}}>{{t "batch-shipping.skipped"}}</Badge>
151+
{{/if}}
152+
</td>
153+
</tr>
154+
{{/each}}
155+
</tbody>
156+
</table>
157+
</div>
158+
<div class="flex justify-between">
159+
<Button @text={{t "batch-shipping.new-batch"}} @onClick={{this.reset}} />
160+
</div>
161+
</ContentPanel>
162+
{{/if}}
163+
</div>

addon/components/batch-shipping.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Component from '@glimmer/component';
2+
import { tracked } from '@glimmer/tracking';
3+
import { action } from '@ember/object';
4+
import { inject as service } from '@ember/service';
5+
6+
/**
7+
* Minimal batch shipping component for Phase 3.
8+
*
9+
* Flow: CSV upload → preview → rate → select → purchase → download.
10+
*
11+
* All API calls go through the existing fetch service to hit
12+
* POST /v1/batch-shipments/rates and POST /v1/batch-shipments/purchase.
13+
*/
14+
export default class BatchShippingComponent extends Component {
15+
@service fetch;
16+
@service notifications;
17+
18+
@tracked step = 'upload'; // upload | preview | rated | purchasing | done
19+
@tracked rows = [];
20+
@tracked rateResults = [];
21+
@tracked purchaseResults = [];
22+
@tracked isLoading = false;
23+
24+
/**
25+
* Parse a CSV file into shipment rows. Expects columns:
26+
* row_id, pickup, dropoff, length, width, height, weight, facilitator
27+
*/
28+
@action async handleFileUpload(event) {
29+
const file = event.target?.files?.[0];
30+
if (!file) return;
31+
32+
const text = await file.text();
33+
const lines = text.trim().split('\n');
34+
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
35+
36+
const parsed = [];
37+
for (let i = 1; i < lines.length; i++) {
38+
const cols = lines[i].split(',').map((c) => c.trim());
39+
const row = {};
40+
headers.forEach((h, idx) => (row[h] = cols[idx] ?? ''));
41+
42+
parsed.push({
43+
row_id: row.row_id || `row_${i - 1}`,
44+
pickup: row.pickup,
45+
dropoff: row.dropoff,
46+
parcels: [
47+
{
48+
length: parseFloat(row.length) || 0,
49+
width: parseFloat(row.width) || 0,
50+
height: parseFloat(row.height) || 0,
51+
weight: parseFloat(row.weight) || 0,
52+
},
53+
],
54+
facilitator: row.facilitator || null,
55+
_rates: null,
56+
_selectedQuoteUuid: null,
57+
_purchaseResult: null,
58+
});
59+
}
60+
61+
this.rows = parsed;
62+
this.step = 'preview';
63+
}
64+
65+
@action async rateAll() {
66+
this.isLoading = true;
67+
try {
68+
const shipments = this.rows.map((r) => ({
69+
row_id: r.row_id,
70+
pickup: r.pickup,
71+
dropoff: r.dropoff,
72+
parcels: r.parcels,
73+
facilitator: r.facilitator,
74+
}));
75+
76+
const response = await this.fetch.post('batch-shipments/rates', { shipments });
77+
const results = response?.results ?? [];
78+
79+
// Merge rate results back into rows by row_id.
80+
const byId = {};
81+
results.forEach((r) => (byId[r.row_id] = r));
82+
83+
this.rows = this.rows.map((row) => {
84+
const result = byId[row.row_id];
85+
return {
86+
...row,
87+
_rates: result?.rates ?? [],
88+
_rateError: result?.status === 'error' ? result.error : null,
89+
_selectedQuoteUuid: result?.rates?.[0]?.uuid ?? null, // default: cheapest (first)
90+
};
91+
});
92+
93+
this.step = 'rated';
94+
} catch (err) {
95+
this.notifications.serverError(err);
96+
} finally {
97+
this.isLoading = false;
98+
}
99+
}
100+
101+
@action selectQuote(row, quoteUuid) {
102+
row._selectedQuoteUuid = quoteUuid;
103+
// Trigger re-render by replacing the array.
104+
this.rows = [...this.rows];
105+
}
106+
107+
@action async purchaseAll() {
108+
this.isLoading = true;
109+
this.step = 'purchasing';
110+
try {
111+
const purchases = this.rows
112+
.filter((r) => r._selectedQuoteUuid)
113+
.map((r) => ({
114+
row_id: r.row_id,
115+
service_quote_uuid: r._selectedQuoteUuid,
116+
}));
117+
118+
const response = await this.fetch.post('batch-shipments/purchase', { purchases });
119+
const results = response?.results ?? [];
120+
121+
const byId = {};
122+
results.forEach((r) => (byId[r.row_id] = r));
123+
124+
this.rows = this.rows.map((row) => ({
125+
...row,
126+
_purchaseResult: byId[row.row_id] ?? null,
127+
}));
128+
129+
this.purchaseResults = results;
130+
this.step = 'done';
131+
} catch (err) {
132+
this.notifications.serverError(err);
133+
this.step = 'rated'; // allow retry
134+
} finally {
135+
this.isLoading = false;
136+
}
137+
}
138+
139+
@action reset() {
140+
this.rows = [];
141+
this.rateResults = [];
142+
this.purchaseResults = [];
143+
this.step = 'upload';
144+
}
145+
}

app/components/batch-shipping.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from '@fleetbase/fleetops-engine/components/batch-shipping';

0 commit comments

Comments
 (0)