package order import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" "git.k6n.net/mats/go-cart-actor/pkg/flow" ) type InventoryReservationLine struct { SKU string `json:"sku"` LocationID string `json:"locationId"` Quantity uint32 `json:"quantity"` } type InventoryReservationRequest struct { HolderID string `json:"holderId"` TTL time.Duration `json:"-"` Lines []InventoryReservationLine `json:"lines"` } type InventoryReservationService interface { Reserve(ctx context.Context, req InventoryReservationRequest) error Release(ctx context.Context, req InventoryReservationRequest) error } type HTTPInventoryReservationService struct { baseURL string client *http.Client } func NewHTTPInventoryReservationService(baseURL string, client *http.Client) (*HTTPInventoryReservationService, error) { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" { return nil, fmt.Errorf("inventory reservation service: missing base url") } if client == nil { client = &http.Client{Timeout: 10 * time.Second} } return &HTTPInventoryReservationService{baseURL: baseURL, client: client}, nil } func (s *HTTPInventoryReservationService) Reserve(ctx context.Context, req InventoryReservationRequest) error { return s.post(ctx, "/reservations", req) } func (s *HTTPInventoryReservationService) Release(ctx context.Context, req InventoryReservationRequest) error { return s.post(ctx, "/reservations/release", req) } func (s *HTTPInventoryReservationService) post(ctx context.Context, path string, req InventoryReservationRequest) error { body := map[string]any{ "holderId": req.HolderID, "lines": req.Lines, } if req.TTL > 0 { body["ttlSeconds"] = int64(req.TTL / time.Second) } raw, err := json.Marshal(body) if err != nil { return err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, bytes.NewReader(raw)) if err != nil { return err } httpReq.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(httpReq) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("inventory reservation service: %s returned %d", path, resp.StatusCode) } return nil } type reserveInventoryParams struct { HolderID string `json:"holderId,omitempty"` TTLSeconds int64 `json:"ttlSeconds,omitempty"` Lines []struct { Reference string `json:"reference"` Quantity int32 `json:"quantity"` LocationID string `json:"locationId,omitempty"` } `json:"lines"` } func RegisterInventoryReservationActions(reg *flow.Registry, app Applier, svc InventoryReservationService) { reg.ActionWithMeta("reserve_inventory", func(ctx context.Context, st *flow.State, params json.RawMessage) error { if svc == nil { return fmt.Errorf("reserve_inventory: no reservation service configured") } req, err := buildInventoryReservationRequest(ctx, app, st, params, "reserve_inventory") if err != nil { return err } return svc.Reserve(ctx, req) }, flow.CapabilityMeta{ Description: "Reserve inventory for specific order lines through the inventory service.", ExampleParams: json.RawMessage(`{"ttlSeconds":900,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`), }) reg.ActionWithMeta("release_inventory", func(ctx context.Context, st *flow.State, params json.RawMessage) error { if svc == nil { return fmt.Errorf("release_inventory: no reservation service configured") } req, err := buildInventoryReservationRequest(ctx, app, st, params, "release_inventory") if err != nil { return err } return svc.Release(ctx, req) }, flow.CapabilityMeta{ Description: "Release a prior inventory reservation for specific order lines.", ExampleParams: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`), }) } func buildInventoryReservationRequest(ctx context.Context, app Applier, st *flow.State, params json.RawMessage, action string) (InventoryReservationRequest, error) { var p reserveInventoryParams if err := json.Unmarshal(params, &p); err != nil { return InventoryReservationRequest{}, fmt.Errorf("%s: bad params: %w", action, err) } if len(p.Lines) == 0 { return InventoryReservationRequest{}, fmt.Errorf("%s: missing lines", action) } o, err := app.Get(ctx, st.ID) if err != nil { return InventoryReservationRequest{}, err } holderID := p.HolderID if holderID == "" { holderID = "order:" + OrderId(st.ID).String() } req := InventoryReservationRequest{HolderID: holderID} if p.TTLSeconds > 0 { req.TTL = time.Duration(p.TTLSeconds) * time.Second } for _, lineReq := range p.Lines { if lineReq.Reference == "" { return InventoryReservationRequest{}, fmt.Errorf("%s: line missing reference", action) } if lineReq.Quantity <= 0 { return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has invalid quantity %d", action, lineReq.Reference, lineReq.Quantity) } line := o.findLine(lineReq.Reference) if line == nil { return InventoryReservationRequest{}, fmt.Errorf("%s: unknown line %q", action, lineReq.Reference) } if line.Sku == "" { return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has no sku", action, lineReq.Reference) } remaining := line.Quantity - line.Fulfilled if int(lineReq.Quantity) > remaining { return InventoryReservationRequest{}, fmt.Errorf("%s: line %q quantity %d exceeds remaining fulfillable %d", action, lineReq.Reference, lineReq.Quantity, remaining) } locationID := strings.TrimSpace(lineReq.LocationID) if locationID == "" { locationID = strings.TrimSpace(line.Location) } if locationID == "" { locationID = strings.TrimSpace(o.Country) } if locationID == "" { return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has no location", action, lineReq.Reference) } req.Lines = append(req.Lines, InventoryReservationLine{ SKU: line.Sku, LocationID: locationID, Quantity: uint32(lineReq.Quantity), }) } return req, nil }