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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions backend/internal/integrations/ailm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 39 additions & 1 deletion backend/internal/integrations/ailm_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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{})),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"},
Expand Down
93 changes: 87 additions & 6 deletions backend/internal/integrations/ailm_store_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/gablelbm/gable/pkg/database"
"github.com/google/uuid"
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand Down
Loading
Loading