diff --git a/README.md b/README.md index e11ef70..ca3520b 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,9 @@ err = cartService.ReserveForCart(ctx, inventory.CartReserveRequest{ // Get available inventory (accounting for reservations) available, err := cartService.GetAvailableInventory(ctx, "item1", "warehouse1") -// Get reservation status and remaining time -active, remaining, err := cartService.GetReservationStatus(ctx, "item1", "warehouse1", "cart123") -if active { - fmt.Printf("Reservation active, %v remaining\n", remaining) -} +// Get reservation summary for an item +summary, err := cartService.GetReservationSummary(ctx, "item1", "warehouse1") +fmt.Printf("Reservations: %d, Next release: %v, Remaining: %d\n", summary.ReservationCount, summary.EarliestRelease, summary.RemainingItems) ``` ## Dependencies diff --git a/pkg/inventory/redis_reservation_service.go b/pkg/inventory/redis_reservation_service.go index 3c3bdd2..ef1aec1 100644 --- a/pkg/inventory/redis_reservation_service.go +++ b/pkg/inventory/redis_reservation_service.go @@ -46,13 +46,13 @@ func totalReservedKey(sku SKU, locationID LocationID) string { func (s *RedisCartReservationService) ReserveForCart(ctx context.Context, req CartReserveRequest) error { keys := []string{ - getInventoryKey(req.Sku, req.LocationID), - cartQtyKey(req.Sku, req.LocationID), - cartExpKey(req.Sku, req.LocationID), - totalReservedKey(req.Sku, req.LocationID), + getInventoryKey(req.SKU, req.LocationID), + cartQtyKey(req.SKU, req.LocationID), + cartExpKey(req.SKU, req.LocationID), + totalReservedKey(req.SKU, req.LocationID), } args := []interface{}{ - string(req.CartId), + string(req.CartID), req.Quantity, int64(req.TTL.Seconds()), } @@ -112,6 +112,39 @@ func (s *RedisCartReservationService) GetReservationStatus(ctx context.Context, }, nil } +func (s *RedisCartReservationService) GetReservationSummary(ctx context.Context, sku SKU, locationID LocationID) (*ReservationSummary, error) { + qtyKey := cartQtyKey(sku, locationID) + expKey := cartExpKey(sku, locationID) + + // Get reservation count + reservationCount, err := s.client.HLen(ctx, qtyKey).Result() + if err != nil { + return nil, err + } + + // Get earliest release (min score in sorted set) + minScore, err := s.client.ZRangeWithScores(ctx, expKey, 0, 0).Result() + if err != nil { + return nil, err + } + var earliestRelease time.Time + if len(minScore) > 0 { + earliestRelease = time.Unix(int64(minScore[0].Score), 0) + } + + // Get remaining items + remainingItems, err := s.GetAvailableInventory(ctx, sku, locationID) + if err != nil { + return nil, err + } + + return &ReservationSummary{ + ReservationCount: reservationCount, + EarliestRelease: earliestRelease, + RemainingItems: remainingItems, + }, nil +} + func (s *RedisCartReservationService) GetAvailableInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) { keys := []string{ getInventoryKey(sku, locationID), diff --git a/pkg/inventory/redis_reservation_service_test.go b/pkg/inventory/redis_reservation_service_test.go index 2d57157..145b59c 100644 --- a/pkg/inventory/redis_reservation_service_test.go +++ b/pkg/inventory/redis_reservation_service_test.go @@ -49,8 +49,8 @@ func TestReserveAndRelease(t *testing.T) { // Reserve req := CartReserveRequest{ - InventoryReference: &InventoryReference{Sku: sku, LocationID: locationID}, - CartId: cartID, + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: cartID, Quantity: 2, TTL: 5 * time.Minute, } @@ -132,8 +132,8 @@ func TestConcurrentReserves(t *testing.T) { go func(cartNum int) { defer wg.Done() req := CartReserveRequest{ - InventoryReference: &InventoryReference{Sku: sku, LocationID: locationID}, - CartId: CartID(fmt.Sprintf("cart%d", cartNum)), + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: CartID(fmt.Sprintf("cart%d", cartNum)), Quantity: 1, TTL: 5 * time.Minute, } @@ -195,8 +195,8 @@ func TestExpiry(t *testing.T) { // Reserve with short TTL req := CartReserveRequest{ - InventoryReference: &InventoryReference{Sku: sku, LocationID: locationID}, - CartId: cartID, + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: cartID, Quantity: 2, TTL: 10 * time.Second, } @@ -239,3 +239,73 @@ func TestExpiry(t *testing.T) { t.Errorf("expected available 8, got %d", available) } } + +func TestGetReservationSummary(t *testing.T) { + client, mr, err := setupTestRedis() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + service, err := NewRedisCartReservationService(client) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + sku := SKU("test-item") + locationID := LocationID("warehouse1") + + // Set initial inventory + err = client.Set(ctx, getInventoryKey(sku, locationID), 10, 0).Err() + if err != nil { + t.Fatal(err) + } + + // Reserve two carts + req1 := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: "cart1", + Quantity: 2, + TTL: 5 * time.Minute, + } + err = service.ReserveForCart(ctx, req1) + if err != nil { + t.Fatal(err) + } + + req2 := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: "cart2", + Quantity: 1, + TTL: 10 * time.Minute, + } + err = service.ReserveForCart(ctx, req2) + if err != nil { + t.Fatal(err) + } + + // Get summary + summary, err := service.GetReservationSummary(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + + if summary.ReservationCount != 2 { + t.Errorf("expected 2 reservations, got %d", summary.ReservationCount) + } + + if summary.RemainingItems != 7 { + t.Errorf("expected remaining 7, got %d", summary.RemainingItems) + } + + if summary.EarliestRelease.IsZero() { + t.Error("expected earliest release time") + } + + // Earliest should be cart1's expiry (5 min) + expectedEarliest := time.Now().Add(5 * time.Minute) + if summary.EarliestRelease.After(expectedEarliest.Add(1*time.Second)) || summary.EarliestRelease.Before(expectedEarliest.Add(-1*time.Second)) { + t.Errorf("earliest release time incorrect: %v", summary.EarliestRelease) + } +} diff --git a/pkg/inventory/redis_service.go b/pkg/inventory/redis_service.go index 14bd5ee..f500e07 100644 --- a/pkg/inventory/redis_service.go +++ b/pkg/inventory/redis_service.go @@ -66,7 +66,7 @@ func (s *RedisInventoryService) GetInventoryBatch(ctx context.Context, refs ...* pipe := s.client.Pipeline() cmds := make([]*redis.StringCmd, len(refs)) for i, ref := range refs { - key := getInventoryKey(ref.Sku, ref.LocationID) + key := getInventoryKey(ref.SKU, ref.LocationID) cmds[i] = pipe.Get(ctx, key) } _, err := pipe.Exec(ctx) @@ -139,7 +139,7 @@ func makeKeysAndArgs(req ...ReserveRequest) ([]string, []string) { if r.Quantity <= 0 { return nil, nil } - keys[i] = getInventoryKey(r.Sku, r.LocationID) + keys[i] = getInventoryKey(r.SKU, r.LocationID) args[i] = strconv.Itoa(int(r.Quantity)) } return keys, args diff --git a/pkg/inventory/types.go b/pkg/inventory/types.go index 7408c30..d584f67 100644 --- a/pkg/inventory/types.go +++ b/pkg/inventory/types.go @@ -16,7 +16,7 @@ type InventoryItem struct { } type InventoryReference struct { - Sku SKU + SKU SKU LocationID LocationID } @@ -47,7 +47,7 @@ type CartID string type CartReserveRequest struct { *InventoryReference - CartId CartID + CartID CartID Quantity uint32 TTL time.Duration } @@ -58,9 +58,16 @@ type CartReservationService interface { GetAvailableInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) GetReservationExpiry(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (time.Time, error) GetReservationStatus(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (*ReservationStatus, error) + GetReservationSummary(ctx context.Context, sku SKU, locationID LocationID) (*ReservationSummary, error) } type ReservationStatus struct { Active bool RemainingTime time.Duration } + +type ReservationSummary struct { + ReservationCount int64 `json:"reservation_count"` + EarliestRelease time.Time `json:"earliest_release"` + RemainingItems int64 `json:"remaining_items"` +}