chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange values, grouped and price-sorted the same way projectOfferPricing lists them, so the /catalog console mirrors what a buyer sees. Internal cosmetics only — no product behaviour change. The value-group order moves to a shared helper (valueGroup) so the offer and the admin list cannot drift.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -147,6 +150,65 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
}
|
||||
|
||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
|
||||
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
|
||||
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||
if pa {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
} else if pa {
|
||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
}
|
||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
||||
})
|
||||
}
|
||||
|
||||
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
|
||||
func adminIsPack(p AdminProduct) bool {
|
||||
for _, a := range p.Atoms {
|
||||
if a.Atom == atomChips {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
|
||||
func adminValueGroup(p AdminProduct) int {
|
||||
hasHints, hasNoAds, hasTournament := false, false, false
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "hints":
|
||||
hasHints = true
|
||||
case "noads_days":
|
||||
hasNoAds = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
}
|
||||
}
|
||||
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||
}
|
||||
|
||||
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
|
||||
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
|
||||
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
|
||||
for _, pr := range p.Prices {
|
||||
if pr.Method == method && pr.Currency == cur {
|
||||
return pr.Amount
|
||||
}
|
||||
}
|
||||
return math.MaxInt64
|
||||
}
|
||||
|
||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||
// product created active must satisfy the sellable shape.
|
||||
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
||||
|
||||
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||
func TestSortAdminCatalog(t *testing.T) {
|
||||
pack := func(title string, rub int64) AdminProduct {
|
||||
return AdminProduct{
|
||||
Title: title,
|
||||
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
||||
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
||||
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
||||
}
|
||||
return p
|
||||
}
|
||||
products := []AdminProduct{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
SortAdminCatalog(products)
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
for i, p := range products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// titles extracts the product titles for a failure message.
|
||||
func titles(products []AdminProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -109,11 +109,7 @@ func projectOfferPricing(entries []catalogEntry) string {
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// offerValueGroup ranks a chip-priced value into the offer's usage groups, in listing order: hints
|
||||
// only (0), no-ads only (1), no-ads + hints (2), then anything carrying the tournament atom (3).
|
||||
// Tournament products are not sellable yet (validateProduct forbids an active one), so group 3 is
|
||||
// empty today; the rank reserves their place for when the tournament economy lands. A value with no
|
||||
// recognised benefit atom sorts after the known groups (defensive — the catalog shape forbids it).
|
||||
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
|
||||
func offerValueGroup(e catalogEntry) int {
|
||||
hasHints, hasNoAds, hasTournament := false, false, false
|
||||
for _, a := range e.atoms {
|
||||
@@ -126,6 +122,16 @@ func offerValueGroup(e catalogEntry) int {
|
||||
hasTournament = true
|
||||
}
|
||||
}
|
||||
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||
}
|
||||
|
||||
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
|
||||
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
|
||||
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
|
||||
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
|
||||
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
|
||||
// the catalog shape forbids it).
|
||||
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
|
||||
switch {
|
||||
case hasTournament:
|
||||
return 3
|
||||
|
||||
@@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||
payments.SortAdminCatalog(products)
|
||||
var view adminconsole.CatalogView
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
|
||||
Reference in New Issue
Block a user