Files
go-cart-actor/pkg/order/inventory_reservation.go
T
mats 46be260fcc
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
refactor
2026-06-29 12:34:58 +02:00

180 lines
5.9 KiB
Go

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
}