diff --git a/backend/internal/integrations/ailm.go b/backend/internal/integrations/ailm.go index ec72842..b6cf0c2 100644 --- a/backend/internal/integrations/ailm.go +++ b/backend/internal/integrations/ailm.go @@ -7,10 +7,18 @@ package integrations // // AI_LM (github.com/gablelbm/gable-ai-lm) is a standalone sidecar service. It // authenticates its operators against this ERP, pulls its source-of-truth data -// (fleet, drivers, a calendar date's confirmed orders, and the product catalog -// with per-unit weight + PIM geometry), runs a 3D packing/routing solver, and -// writes approved delivery routes — including the packing manifest that powers -// the yard "Pack Trucks" instructions — back onto the dispatch board. +// (fleet, drivers, the dealer's branches, a calendar date's confirmed orders, +// and the product catalog with per-unit weight + PIM geometry), runs a 3D +// packing/routing solver, and writes approved delivery routes — including the +// packing manifest that powers the yard "Pack Trucks" instructions — back onto +// the dispatch board. +// +// The branch surface exists because a dealer with more than one yard ships from +// several yards on the same day. Every order carries the branch it ships from +// (orders.branch_id, NOT NULL since migration 062) and every branch carries its +// geocoded coordinates (locations.latitude/longitude, migration 072), so AI_LM +// can root a route at the yard the load actually leaves from instead of at one +// globally configured depot. // // Every endpoint here is gated by the X-Integration-Key header (see // Handler.authMiddleware) and every list endpoint returns a BARE JSON ARRAY, @@ -72,6 +80,27 @@ type DriverResponse struct { Status string `json:"status"` // ACTIVE / INACTIVE / ON_LEAVE } +// LocationResponse is one of the dealer's branches — a yard AI_LM may root a +// route at. Mirrors gable.Location. +// +// Latitude and Longitude are POINTERS because locations.latitude/longitude are +// lazily backfilled (migration 072): a branch nobody has geocoded yet is NULL, +// and NULL must stay distinguishable from a real 0.0. Null island is in the +// Gulf of Guinea, so a COALESCE(...,0) here would silently root a whole day's +// routes off the coast of Africa instead of telling AI_LM "I don't know where +// this yard is, fall back". +// +// Address is the composed "street, city, state zip" free-text form, i.e. what +// you would hand a geocoder — the same composition delivery.GetBranchOrigin +// uses, so a branch geocoded by either path resolves to the same place. +type LocationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` // nil = never geocoded + Longitude *float64 `json:"longitude,omitempty"` // nil = never geocoded +} + // ProductResponse is a catalog product with its per-unit weight and the PIM's // canonical parametric geometry. Mirrors gable.Product. // @@ -111,9 +140,17 @@ type IntegrationOrderLine struct { // for it has been geocoded, its destination coordinates. Mirrors gable.Order. // ScheduledDate is an ERP-side extra (AI_LM ignores it) that echoes back which // date the row matched, which makes a wrong ?date= filter obvious. +// +// BranchID is the yard this order ships from. It is deliberately NOT omitempty: +// orders.branch_id is NOT NULL (migration 062), so an empty string on the wire +// is a bug in this ERP, and AI_LM must be able to SEE that bug rather than +// receive a payload in which "no branch" and "field dropped" look identical. +// AI_LM cross-references it against GET /api/integration/locations to pick the +// depot a plan is rooted at. type IntegrationOrderResponse struct { ID string `json:"id"` Status string `json:"status"` + BranchID string `json:"branch_id"` // orders.branch_id; never empty CustomerName string `json:"customer_name,omitempty"` Address string `json:"address,omitempty"` Latitude *float64 `json:"latitude,omitempty"` // nil = not geocoded @@ -211,6 +248,7 @@ type staffLookup struct { type ailmStore interface { ListVehicles(ctx context.Context) ([]VehicleResponse, error) ListDrivers(ctx context.Context) ([]DriverResponse, error) + ListLocations(ctx context.Context) ([]LocationResponse, error) ListProducts(ctx context.Context, f productFilter) ([]ProductResponse, error) ListOrders(ctx context.Context, date, status string) ([]IntegrationOrderResponse, error) ReplaceDeliveryRoute(ctx context.Context, req DeliveryRouteRequest) (*DeliveryRouteResponse, error) @@ -245,6 +283,26 @@ func (h *Handler) ListDrivers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, nonNil(drivers)) } +// --- branches --------------------------------------------------------------- + +// ListLocations returns the dealer's active branches as a bare JSON array. +// +// GET /api/integration/locations +// +// AI_LM joins this to orders.branch_id to root a plan at the yard the load +// actually leaves from. A branch with null coordinates is returned anyway — +// AI_LM needs to know the branch EXISTS in order to report "this yard has never +// been geocoded" rather than silently planning from somewhere else. +func (h *Handler) ListLocations(w http.ResponseWriter, r *http.Request) { + locations, err := h.ailm.ListLocations(r.Context()) + if err != nil { + slog.Error("integration: list locations", "error", err, "method", r.Method, "path", r.URL.Path) + writeError(w, http.StatusInternalServerError, "failed to query locations") + return + } + writeJSON(w, http.StatusOK, nonNil(locations)) +} + // --- orders ----------------------------------------------------------------- // ListOrdersForDate returns orders with their line items and per-unit weights. diff --git a/backend/internal/integrations/ailm_contract_test.go b/backend/internal/integrations/ailm_contract_test.go index 799da90..28a9f1a 100644 --- a/backend/internal/integrations/ailm_contract_test.go +++ b/backend/internal/integrations/ailm_contract_test.go @@ -7,7 +7,7 @@ package integrations // // THE PROBLEM // GableLBM and AI_LM (github.com/gablelbm/gable-ai-lm) are two independently -// released repositories joined by six HTTP endpoints. Until this file existed +// released repositories joined by seven HTTP endpoints. Until this file existed // the contract between them was prose plus struct tags: nothing mechanical // stopped someone renaming a JSON field, collapsing a nullable pointer to a // zero value, or wrapping a bare array in an envelope. Every such change @@ -75,6 +75,16 @@ const aiLMClientSource = "gable-ai-lm/backend/internal/gable/client.go" // Copy these verbatim from aiLMClientSource. Do NOT "fix" them to match a // GableLBM change — that inverts the direction of the contract. Field order, // Go types, pointer-ness and struct tags all matter. +// +// EXCEPTION, and the only kind there should ever be: a field or type added as +// half of a coordinated cross-repo change lands here at the same time as it +// lands in AI_LM's client.go, since neither repo can merge a seam that only one +// side knows about. aiLMLocation and aiLMOrder.BranchID are that case (the +// branch-depot work: AI_LM roots a route at the yard an order ships from +// instead of at one global DEPOT_LAT/DEPOT_LNG). They are written here as the +// AGREED shape and AI_LM's gable.Location / gable.Order must match them field +// for field, tag for tag, in the same order. If they ever diverge, this file is +// not the thing to edit — re-sync from aiLMClientSource. type aiLMVehicle struct { ID string `json:"id"` @@ -93,6 +103,21 @@ type aiLMDriver struct { Status string `json:"status"` } +// aiLMLocation mirrors gable.Location — one of the dealer's branches, which +// AI_LM matches against Order.BranchID to root a plan at the yard the load +// actually leaves from instead of at one globally configured depot. +// +// Latitude/Longitude are pointers: locations.latitude/longitude are backfilled +// lazily, and nil ("this yard has never been geocoded") must not collapse into +// 0,0. That collapse is precisely what this contract suite exists to catch. +type aiLMLocation struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` +} + type aiLMProduct struct { ID string `json:"id"` SKU string `json:"sku"` @@ -117,6 +142,7 @@ type aiLMOrderLine struct { type aiLMOrder struct { ID string `json:"id"` Status string `json:"status"` + BranchID string `json:"branch_id"` CustomerName string `json:"customer_name,omitempty"` Address string `json:"address,omitempty"` Latitude *float64 `json:"latitude,omitempty"` @@ -201,6 +227,7 @@ func TestAILMClientContractUnchanged(t *testing.T) { "types": map[string][]contractField{ "Vehicle": describeType(reflect.TypeOf(aiLMVehicle{})), "Driver": describeType(reflect.TypeOf(aiLMDriver{})), + "Location": describeType(reflect.TypeOf(aiLMLocation{})), "Product": describeType(reflect.TypeOf(aiLMProduct{})), "OrderLine": describeType(reflect.TypeOf(aiLMOrderLine{})), "Order": describeType(reflect.TypeOf(aiLMOrder{})), @@ -250,6 +277,16 @@ func TestAILMResponseContract(t *testing.T) { return v }, }, + { + name: "locations", + target: "/api/integration/locations", + golden: "ailm_locations.json", + decode: func(t *testing.T, body []byte) any { + var v []aiLMLocation + mustDecode(t, body, &v) + return v + }, + }, { name: "products", target: "/api/integration/products", @@ -423,6 +460,7 @@ func TestAILMEndpointInventory(t *testing.T) { {http.MethodGet, "/api/integration/products"}, {http.MethodGet, "/api/integration/vehicles"}, {http.MethodGet, "/api/integration/drivers"}, + {http.MethodGet, "/api/integration/locations"}, {http.MethodGet, "/api/integration/orders"}, {http.MethodPost, "/api/integration/delivery-routes"}, {http.MethodPost, "/api/integration/validate-staff"}, diff --git a/backend/internal/integrations/ailm_store_pg.go b/backend/internal/integrations/ailm_store_pg.go index 5eaeb8c..a349a94 100644 --- a/backend/internal/integrations/ailm_store_pg.go +++ b/backend/internal/integrations/ailm_store_pg.go @@ -13,6 +13,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/gablelbm/gable/pkg/database" "github.com/google/uuid" @@ -84,6 +85,80 @@ func (s *pgAILMStore) ListDrivers(ctx context.Context) ([]DriverResponse, error) return out, rows.Err() } +// ListLocations returns the dealer's active branches with their lazily +// backfilled coordinates. +// +// A branch is a locations row with type='BRANCH' AND parent_id IS NULL (see +// migration 057's chk_branch_is_root); the parent_id predicate is redundant +// under that constraint but stated anyway so the query still means "top-level +// yard" if the constraint is ever relaxed. +// +// latitude/longitude are deliberately NOT COALESCEd. They are nullable by +// design (migration 072 backfills them the first time a route is optimized from +// that branch), and a COALESCE(...,0) would hand AI_LM a yard at 0,0 — null +// island, in the Gulf of Guinea — which it has no way to tell apart from a real +// coordinate. Nil instead makes "never geocoded" explicit, and AI_LM falls back +// to its configured depot and says so on the plan. +func (s *pgAILMStore) ListLocations(ctx context.Context) ([]LocationResponse, error) { + const q = ` + SELECT l.id::text, COALESCE(NULLIF(l.name, ''), l.code), + l.latitude, l.longitude, + l.address, l.city, l.state, l.zip + FROM locations l + WHERE l.type = 'BRANCH' AND l.parent_id IS NULL AND l.active = TRUE + ORDER BY COALESCE(NULLIF(l.name, ''), l.code), l.id` + + rows, err := s.db.GetExecutor(ctx).Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("query locations: %w", err) + } + defer rows.Close() + + out := []LocationResponse{} + for rows.Next() { + var ( + loc LocationResponse + addr, city, state, zip *string + ) + if err := rows.Scan(&loc.ID, &loc.Name, &loc.Latitude, &loc.Longitude, + &addr, &city, &state, &zip); err != nil { + return nil, fmt.Errorf("scan location: %w", err) + } + loc.Address = composeBranchAddress(addr, city, state, zip) + out = append(out, loc) + } + return out, rows.Err() +} + +// composeBranchAddress joins a branch's structured address parts into the single +// free-text line a geocoder wants, e.g. "2450 Enterprise Way, Kelowna, BC V1X 7K2". +// +// Duplicated from internal/delivery deliberately: internal/integrations must not +// import internal/delivery (the integration surface is a leaf that has to stay +// httptest-able without the delivery module's dependencies). The rule it encodes +// — skip empty parts, glue state and zip with a space — must match +// delivery.composeBranchAddress, because a branch geocoded through either path +// has to resolve to the same point. +func composeBranchAddress(addr, city, state, zip *string) string { + deref := func(p *string) string { + if p == nil { + return "" + } + return strings.TrimSpace(*p) + } + parts := []string{} + if a := deref(addr); a != "" { + parts = append(parts, a) + } + if c := deref(city); c != "" { + parts = append(parts, c) + } + if sz := strings.TrimSpace(deref(state) + " " + deref(zip)); sz != "" { + parts = append(parts, sz) + } + return strings.Join(parts, ", ") +} + // ListProducts returns the catalog slice described by f, with per-unit weight // and the PIM's parametric geometry attached. // @@ -164,12 +239,17 @@ func resolveGeometrySource(p ProductResponse) string { // moment AI_LM writes a route back, because a second deliveries row appears for // the same order and every line would be emitted twice. // +// branch_id is selected as a plain (NOT NULL) column: every order has shipped +// from a yard since migration 062, and AI_LM roots its route at that yard's +// coordinates rather than at one global depot. +// // The date filter matches COALESCE(scheduled_delivery_date, created_at::date) // so orders predating migration 080 (which have no scheduled date) still // resolve by their creation date instead of vanishing from the board. func (s *pgAILMStore) ListOrders(ctx context.Context, date, status string) ([]IntegrationOrderResponse, error) { q := ` - SELECT o.id::text, o.status, COALESCE(c.name, ''), COALESCE(c.address, ''), + SELECT o.id::text, o.status, o.branch_id::text, + COALESCE(c.name, ''), COALESCE(c.address, ''), COALESCE(to_char(COALESCE(o.scheduled_delivery_date, o.created_at::date), 'YYYY-MM-DD'), ''), d.latitude, d.longitude, ol.product_id::text, p.sku, ol.quantity, COALESCE(p.weight_lbs, 0) @@ -210,12 +290,12 @@ func (s *pgAILMStore) ListOrders(ctx context.Context, date, status string) ([]In order := []string{} for rows.Next() { var ( - id, status, custName, custAddr, schedDate string - lat, lng *float64 - productID, sku string - qty, weight float64 + id, status, branchID, custName, custAddr, schedDate string + lat, lng *float64 + productID, sku string + qty, weight float64 ) - if err := rows.Scan(&id, &status, &custName, &custAddr, &schedDate, + if err := rows.Scan(&id, &status, &branchID, &custName, &custAddr, &schedDate, &lat, &lng, &productID, &sku, &qty, &weight); err != nil { return nil, fmt.Errorf("scan order row: %w", err) } @@ -225,6 +305,7 @@ func (s *pgAILMStore) ListOrders(ctx context.Context, date, status string) ([]In o = &IntegrationOrderResponse{ ID: id, Status: status, + BranchID: branchID, CustomerName: custName, Address: custAddr, Latitude: lat, diff --git a/backend/internal/integrations/ailm_test.go b/backend/internal/integrations/ailm_test.go index 7df6a08..62a3c4f 100644 --- a/backend/internal/integrations/ailm_test.go +++ b/backend/internal/integrations/ailm_test.go @@ -24,12 +24,13 @@ const testKey = "test-integration-key" // --- fake store ------------------------------------------------------------- type fakeAILMStore struct { - vehicles []VehicleResponse - drivers []DriverResponse - products []ProductResponse - orders []IntegrationOrderResponse - route *DeliveryRouteResponse - staff map[string]*staffLookup + vehicles []VehicleResponse + drivers []DriverResponse + locations []LocationResponse + products []ProductResponse + orders []IntegrationOrderResponse + route *DeliveryRouteResponse + staff map[string]*staffLookup // err, when set, is returned by every method so the 500 paths are covered. err error @@ -50,6 +51,10 @@ func (f *fakeAILMStore) ListDrivers(context.Context) ([]DriverResponse, error) { return f.drivers, f.err } +func (f *fakeAILMStore) ListLocations(context.Context) ([]LocationResponse, error) { + return f.locations, f.err +} + func (f *fakeAILMStore) ListProducts(_ context.Context, filter productFilter) ([]ProductResponse, error) { f.gotProductFilter = filter return f.products, f.err @@ -95,6 +100,8 @@ const ( vehicleUUID = "11111111-1111-4111-8111-111111111111" vehicle2UUID = "11111111-1111-4111-8111-222222222222" driverUUID = "22222222-2222-4222-8222-111111111111" + branchUUID = "77777777-7777-4777-8777-111111111111" + branch2UUID = "77777777-7777-4777-8777-222222222222" productUUID = "33333333-3333-4333-8333-111111111111" product2UUID = "33333333-3333-4333-8333-222222222222" orderUUID = "44444444-4444-4444-8444-111111111111" @@ -135,6 +142,27 @@ func fixtureDrivers() []DriverResponse { } } +func fixtureLocations() []LocationResponse { + return []LocationResponse{ + { + ID: branchUUID, + Name: "Kelowna Yard", + Address: "2450 Enterprise Way, Kelowna, BC V1X 7K2", + Latitude: f64(49.8879), + Longitude: f64(-119.4960), + }, + { + // Never geocoded — locations.latitude/longitude are backfilled + // lazily (migration 072). latitude/longitude MUST be absent, not + // 0/0: AI_LM has to be able to say "this yard has no coordinates, + // falling back" instead of rooting the day's routes at null island. + ID: branch2UUID, + Name: "Vernon Yard", + Address: "115 Kalamalka Rd, Vernon, BC V1T 6V1", + }, + } +} + func fixtureProducts() []ProductResponse { return []ProductResponse{ { @@ -171,6 +199,7 @@ func fixtureOrders() []IntegrationOrderResponse { { ID: orderUUID, Status: "CONFIRMED", + BranchID: branchUUID, CustomerName: "Kelbrook Homes", Address: "1885 Formwork Rd, Kelowna BC", Latitude: f64(49.8801), @@ -186,6 +215,7 @@ func fixtureOrders() []IntegrationOrderResponse { // null island is in the Gulf of Guinea and would wreck the route. ID: order2UUID, Status: "CONFIRMED", + BranchID: branchUUID, CustomerName: "Okanagan Builders", Address: "9000 Blueprint Pkwy, Kelowna BC", ScheduledDate: "2026-08-21", @@ -224,11 +254,12 @@ func fixtureStaff() map[string]*staffLookup { func fullFakeStore() *fakeAILMStore { return &fakeAILMStore{ - vehicles: fixtureVehicles(), - drivers: fixtureDrivers(), - products: fixtureProducts(), - orders: fixtureOrders(), - staff: fixtureStaff(), + vehicles: fixtureVehicles(), + drivers: fixtureDrivers(), + locations: fixtureLocations(), + products: fixtureProducts(), + orders: fixtureOrders(), + staff: fixtureStaff(), } } @@ -303,6 +334,7 @@ func TestAILMEndpointsRequireIntegrationKey(t *testing.T) { {http.MethodGet, "/api/integration/products", ""}, {http.MethodGet, "/api/integration/vehicles", ""}, {http.MethodGet, "/api/integration/drivers", ""}, + {http.MethodGet, "/api/integration/locations", ""}, {http.MethodGet, "/api/integration/orders?date=2026-08-21&status=CONFIRMED", ""}, {http.MethodPost, "/api/integration/delivery-routes", `{"vehicle_id":"` + vehicleUUID + `"}`}, {http.MethodPost, "/api/integration/validate-staff", `{"email":"dispatcher@gable.com"}`}, @@ -437,6 +469,152 @@ func TestListDriversWireShape(t *testing.T) { } } +// --- branches --------------------------------------------------------------- + +func TestListLocationsWireShape(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + store *fakeAILMStore + wantCode int + wantJSON string + }{ + { + name: "a geocoded yard and one that has never been geocoded", + store: fullFakeStore(), + wantCode: http.StatusOK, + wantJSON: `[ + {"id":"` + branchUUID + `","name":"Kelowna Yard", + "address":"2450 Enterprise Way, Kelowna, BC V1X 7K2", + "latitude":49.8879,"longitude":-119.4960}, + {"id":"` + branch2UUID + `","name":"Vernon Yard", + "address":"115 Kalamalka Rd, Vernon, BC V1T 6V1"} + ]`, + }, + { + name: "no branches is [] not null", + store: &fakeAILMStore{}, + wantCode: http.StatusOK, + wantJSON: `[]`, + }, + { + name: "store failure is 500", + store: &fakeAILMStore{err: errors.New("boom")}, + wantCode: http.StatusInternalServerError, + wantJSON: `{"error":"failed to query locations"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(tc.store, testKey) + w := do(t, srv, http.MethodGet, "/api/integration/locations", testKey, "") + if w.Code != tc.wantCode { + t.Fatalf("status = %d, want %d (body %s)", w.Code, tc.wantCode, w.Body) + } + if tc.wantCode == http.StatusOK { + assertBareArray(t, w.Body.Bytes()) + } + assertJSON(t, w.Body.Bytes(), tc.wantJSON) + }) + } +} + +// A branch that has never been geocoded must OMIT latitude/longitude rather +// than send 0/0. This is the whole reason LocationResponse uses *float64: AI_LM +// roots a route at the branch depot, and a 0,0 it cannot distinguish from a +// real coordinate would plan the day's deliveries from the Gulf of Guinea. +func TestBranchMissingCoordinatesAreOmitted(t *testing.T) { + t.Parallel() + + srv := newTestServer(fullFakeStore(), testKey) + w := do(t, srv, http.MethodGet, "/api/integration/locations", testKey, "") + + var raw []map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + if len(raw) != 2 { + t.Fatalf("got %d branches, want 2", len(raw)) + } + for _, field := range []string{"latitude", "longitude"} { + if v, ok := raw[1][field]; ok { + t.Errorf("%s = %s on a branch that was never geocoded; want the key absent "+ + "so AI_LM sees nil and falls back, not null island", field, v) + } + } + + // ...and the geocoded branch must still carry real coordinates, so the + // assertion above cannot be satisfied by emitting nothing at all. + if got := string(raw[0]["latitude"]); got != "49.8879" { + t.Errorf("latitude = %s on the geocoded branch, want 49.8879", got) + } + if got := string(raw[0]["longitude"]); got != "-119.496" { + t.Errorf("longitude = %s on the geocoded branch, want -119.496", got) + } +} + +// composeBranchAddress builds the free-text line a geocoder is handed, so a +// branch whose address is only partly filled in must not produce stray commas +// or a leading/trailing separator — OpenRouteService resolves ", , BC" to +// something unhelpful and the yard ends up in the wrong place. +// +// The rule must stay identical to delivery.composeBranchAddress (the two are +// deliberate duplicates; internal/integrations must not import +// internal/delivery), because a branch geocoded through either path has to +// resolve to the same point. +func TestComposeBranchAddress(t *testing.T) { + t.Parallel() + + sp := func(v string) *string { return &v } + + cases := []struct { + name string + addr, city, state, zip *string + want string + }{ + { + name: "every part present", + addr: sp("2450 Enterprise Way"), city: sp("Kelowna"), + state: sp("BC"), zip: sp("V1X 7K2"), + want: "2450 Enterprise Way, Kelowna, BC V1X 7K2", + }, + { + name: "no zip keeps the state alone, with no trailing space", + addr: sp("115 Kalamalka Rd"), city: sp("Vernon"), state: sp("BC"), + want: "115 Kalamalka Rd, Vernon, BC", + }, + { + name: "no state keeps the zip alone", + addr: sp("115 Kalamalka Rd"), city: sp("Vernon"), zip: sp("V1T 6V1"), + want: "115 Kalamalka Rd, Vernon, V1T 6V1", + }, + { + name: "missing street does not leave a leading comma", + city: sp("Vernon"), state: sp("BC"), zip: sp("V1T 6V1"), + want: "Vernon, BC V1T 6V1", + }, + { + name: "whitespace-only parts count as absent", + addr: sp(" "), city: sp("Vernon"), state: sp(" "), zip: sp(""), + want: "Vernon", + }, + { + name: "a branch with no address at all is the empty string", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := composeBranchAddress(tc.addr, tc.city, tc.state, tc.zip); got != tc.want { + t.Errorf("composeBranchAddress = %q, want %q", got, tc.want) + } + }) + } +} + // --- products --------------------------------------------------------------- // The bare bulk pull is the regression this endpoint existed to break: AI_LM's @@ -604,14 +782,16 @@ func TestListOrdersWireShape(t *testing.T) { wantDate: "2026-08-21", wantStatus: "CONFIRMED", wantJSON: `[ - {"id":"` + orderUUID + `","status":"CONFIRMED","customer_name":"Kelbrook Homes", + {"id":"` + orderUUID + `","status":"CONFIRMED","branch_id":"` + branchUUID + `", + "customer_name":"Kelbrook Homes", "address":"1885 Formwork Rd, Kelowna BC","latitude":49.8801,"longitude":-119.4436, "scheduled_date":"2026-08-21", "lines":[ {"product_id":"` + productUUID + `","sku":"LUM-248-PREM","quantity":128,"weight_lbs":9.5}, {"product_id":"` + product2UUID + `","sku":"HW-NAIL-16D","quantity":2,"weight_lbs":50} ]}, - {"id":"` + order2UUID + `","status":"CONFIRMED","customer_name":"Okanagan Builders", + {"id":"` + order2UUID + `","status":"CONFIRMED","branch_id":"` + branchUUID + `", + "customer_name":"Okanagan Builders", "address":"9000 Blueprint Pkwy, Kelowna BC","scheduled_date":"2026-08-21", "lines":[ {"product_id":"` + productUUID + `","sku":"LUM-248-PREM","quantity":40,"weight_lbs":9.5} @@ -685,6 +865,34 @@ func TestOrderMissingCoordinatesAreOmitted(t *testing.T) { } } +// branch_id must be present on EVERY order, always. orders.branch_id is NOT +// NULL (migration 062), so the field is not omitempty: if it ever arrives empty +// or absent, that is an ERP bug and AI_LM has to be able to see it rather than +// quietly plan the load from its fallback depot. +func TestOrderAlwaysCarriesBranchID(t *testing.T) { + t.Parallel() + + srv := newTestServer(fullFakeStore(), testKey) + w := do(t, srv, http.MethodGet, "/api/integration/orders", testKey, "") + + var raw []map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + if len(raw) != 2 { + t.Fatalf("got %d orders, want 2", len(raw)) + } + for i, o := range raw { + v, ok := o["branch_id"] + if !ok { + t.Fatalf("order %d has no branch_id; AI_LM cannot resolve its depot", i) + } + if want := `"` + branchUUID + `"`; string(v) != want { + t.Errorf("order %d branch_id = %s, want %s", i, v, want) + } + } +} + // --- delivery route write-back ---------------------------------------------- func TestCreateDeliveryRoute(t *testing.T) { diff --git a/backend/internal/integrations/handler.go b/backend/internal/integrations/handler.go index a5ccfac..6e3ac82 100644 --- a/backend/internal/integrations/handler.go +++ b/backend/internal/integrations/handler.go @@ -58,6 +58,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // ailm.go for the wire contract these satisfy. mux.HandleFunc("GET /api/integration/vehicles", h.authMiddleware(h.ListVehicles)) mux.HandleFunc("GET /api/integration/drivers", h.authMiddleware(h.ListDrivers)) + mux.HandleFunc("GET /api/integration/locations", h.authMiddleware(h.ListLocations)) mux.HandleFunc("GET /api/integration/orders", h.authMiddleware(h.ListOrdersForDate)) mux.HandleFunc("POST /api/integration/delivery-routes", h.authMiddleware(h.CreateDeliveryRoute)) mux.HandleFunc("POST /api/integration/validate-staff", h.authMiddleware(h.ValidateStaff)) diff --git a/backend/internal/integrations/testdata/ailm_client_contract.json b/backend/internal/integrations/testdata/ailm_client_contract.json index 1849939..bc4b2b3 100644 --- a/backend/internal/integrations/testdata/ailm_client_contract.json +++ b/backend/internal/integrations/testdata/ailm_client_contract.json @@ -53,6 +53,38 @@ "omitempty": false } ], + "Location": [ + { + "json_name": "id", + "go_type": "string", + "pointer": false, + "omitempty": false + }, + { + "json_name": "name", + "go_type": "string", + "pointer": false, + "omitempty": false + }, + { + "json_name": "address", + "go_type": "string", + "pointer": false, + "omitempty": true + }, + { + "json_name": "latitude", + "go_type": "float64", + "pointer": true, + "omitempty": true + }, + { + "json_name": "longitude", + "go_type": "float64", + "pointer": true, + "omitempty": true + } + ], "Order": [ { "json_name": "id", @@ -66,6 +98,12 @@ "pointer": false, "omitempty": false }, + { + "json_name": "branch_id", + "go_type": "string", + "pointer": false, + "omitempty": false + }, { "json_name": "customer_name", "go_type": "string", diff --git a/backend/internal/integrations/testdata/ailm_locations.json b/backend/internal/integrations/testdata/ailm_locations.json new file mode 100644 index 0000000..eb37042 --- /dev/null +++ b/backend/internal/integrations/testdata/ailm_locations.json @@ -0,0 +1,14 @@ +[ + { + "id": "77777777-7777-4777-8777-111111111111", + "name": "Kelowna Yard", + "address": "2450 Enterprise Way, Kelowna, BC V1X 7K2", + "latitude": 49.8879, + "longitude": -119.496 + }, + { + "id": "77777777-7777-4777-8777-222222222222", + "name": "Vernon Yard", + "address": "115 Kalamalka Rd, Vernon, BC V1T 6V1" + } +] diff --git a/backend/internal/integrations/testdata/ailm_orders.json b/backend/internal/integrations/testdata/ailm_orders.json index 3d9f61e..bf7271e 100644 --- a/backend/internal/integrations/testdata/ailm_orders.json +++ b/backend/internal/integrations/testdata/ailm_orders.json @@ -2,6 +2,7 @@ { "id": "44444444-4444-4444-8444-111111111111", "status": "CONFIRMED", + "branch_id": "77777777-7777-4777-8777-111111111111", "customer_name": "Kelbrook Homes", "address": "1885 Formwork Rd, Kelowna BC", "latitude": 49.8801, @@ -24,6 +25,7 @@ { "id": "44444444-4444-4444-8444-222222222222", "status": "CONFIRMED", + "branch_id": "77777777-7777-4777-8777-111111111111", "customer_name": "Okanagan Builders", "address": "9000 Blueprint Pkwy, Kelowna BC", "lines": [