diff --git a/pkg/inventory/redis_reservation_service.go b/pkg/inventory/redis_reservation_service.go index 8011007..bf74077 100644 --- a/pkg/inventory/redis_reservation_service.go +++ b/pkg/inventory/redis_reservation_service.go @@ -3,6 +3,7 @@ package inventory import ( "context" "fmt" + "strconv" "time" "github.com/redis/go-redis/v9" @@ -151,6 +152,75 @@ func (s *RedisCartReservationService) GetReservationSummary(ctx context.Context, }, nil } +// GetReservationDetail returns the full per-cart reservation detail for a +// SKU+location. Unlike GetReservationSummary (which returns only aggregates), +// this method fetches every active (non-expired) row from Redis and returns +// a ReservationDetailResponse containing the adjusted summary + per-cart lines. +// Expired rows are filtered out so the UI only sees live holds. The two Redis +// fetches (HGetAll + ZRangeWithScores) run sequentially — for an infrequent +// admin endpoint the goroutine+channel overhead is not worth the latency win. +func (s *RedisCartReservationService) GetReservationDetail(ctx context.Context, sku SKU, locationID LocationID) (*ReservationDetailResponse, error) { + qtyKey := cartQtyKey(sku, locationID) + expKey := cartExpKey(sku, locationID) + + // Fetch all cart→qty pairs. + qtyMap, err := s.client.HGetAll(ctx, qtyKey).Result() + if err != nil { + return nil, err + } + + // Fetch all cart→expiry pairs. + expPairs, err := s.client.ZRangeWithScores(ctx, expKey, 0, -1).Result() + if err != nil { + return nil, err + } + + // Index expiry by cartId. + expMap := make(map[string]time.Time, len(expPairs)) + for _, z := range expPairs { + key, ok := z.Member.(string) + if !ok { + continue + } + expMap[key] = time.Unix(int64(z.Score), 0) + } + + // Build the line list, skipping expired entries. + now := time.Now() + var lines []ReservationDetailLine + var earliestRelease time.Time + for cartID, qtyStr := range qtyMap { + expiresAt, hasExp := expMap[cartID] + if !hasExp || expiresAt.Before(now) { + continue + } + qty, _ := strconv.ParseUint(qtyStr, 10, 32) + + lines = append(lines, ReservationDetailLine{ + CartID: cartID, + Quantity: uint32(qty), + ExpiresAt: expiresAt, + }) + if earliestRelease.IsZero() || expiresAt.Before(earliestRelease) { + earliestRelease = expiresAt + } + } + + remainingItems, err := s.Available(ctx, sku, locationID) + if err != nil { + return nil, err + } + + return &ReservationDetailResponse{ + Summary: &ReservationSummary{ + ReservationCount: int64(len(lines)), + EarliestRelease: earliestRelease, + RemainingItems: remainingItems, + }, + Lines: lines, + }, nil +} + // Available satisfies inventory.ReservationPolicy (platform/inventory). // Method renamed from GetAvailableInventory as part of cut-30.x: land the // ReservationPolicy interface seam — see docs/inventory-shape.md. diff --git a/pkg/inventory/request_types.go b/pkg/inventory/request_types.go index c35fb53..b65cc01 100644 --- a/pkg/inventory/request_types.go +++ b/pkg/inventory/request_types.go @@ -35,3 +35,21 @@ type ReservationSummary struct { EarliestRelease time.Time `json:"earliest_release"` RemainingItems int64 `json:"remaining_items"` } + +// ReservationDetailLine is one row of the per-cart reservation detail, +// returned by GetReservationDetail when the HTTP caller sets ?detail=true. +// Expired reservations (expires_at in the past) are filtered out — the +// list shows only active holds. +type ReservationDetailLine struct { + CartID string `json:"cart_id"` + Quantity uint32 `json:"quantity"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ReservationDetailResponse is the wire shape when ?detail=true on +// GET /reservations/{sku}/{locationId}. Carries the aggregate summary +// alongside the per-cart lines so the UI can show both in one fetch. +type ReservationDetailResponse struct { + Summary *ReservationSummary `json:"summary"` + Lines []ReservationDetailLine `json:"lines"` +}