Fix product performance grouped JSON build
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -581,6 +582,35 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta (
|
|||||||
row_count INTEGER NOT NULL DEFAULT 0,
|
row_count INTEGER NOT NULL DEFAULT 0,
|
||||||
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
duration_ms BIGINT NOT NULL DEFAULT 0
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot (
|
||||||
|
report_key TEXT NOT NULL,
|
||||||
|
row_order INTEGER NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
group_levels_key TEXT NOT NULL,
|
||||||
|
main_group TEXT NOT NULL DEFAULT '',
|
||||||
|
group_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
group_key TEXT NOT NULL DEFAULT '',
|
||||||
|
parent_key TEXT NOT NULL DEFAULT '',
|
||||||
|
group_field TEXT NOT NULL DEFAULT '',
|
||||||
|
group_value TEXT NOT NULL DEFAULT '',
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT pk_mk_product_performance_grouped_snapshot PRIMARY KEY (report_key, row_order)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_key ON mk_product_performance_grouped_snapshot (report_key, row_order)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_level ON mk_product_performance_grouped_snapshot (report_key, group_level, row_order)`,
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta (
|
||||||
|
report_key TEXT PRIMARY KEY,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
group_levels_key TEXT NOT NULL,
|
||||||
|
main_group TEXT NOT NULL DEFAULT '',
|
||||||
|
row_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||||
)`,
|
)`,
|
||||||
}
|
}
|
||||||
for _, stmt := range stmts {
|
for _, stmt := range stmts {
|
||||||
@@ -669,6 +699,62 @@ SELECT EXISTS (
|
|||||||
return out, exists, nil
|
return out, exists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadProductPerformanceSnapshotMapRows(ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]map[string]any, bool, error) {
|
||||||
|
if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50000 {
|
||||||
|
limit = 50000
|
||||||
|
}
|
||||||
|
rows, err := pg.QueryContext(ctx, `
|
||||||
|
SELECT payload
|
||||||
|
FROM mk_product_performance_report_snapshot
|
||||||
|
WHERE report_key = $1
|
||||||
|
ORDER BY row_order
|
||||||
|
LIMIT $2
|
||||||
|
`, reportKey, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var item map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &item); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
item = map[string]any{}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
if err := pg.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM mk_product_performance_report_snapshot_meta
|
||||||
|
WHERE report_key = $1
|
||||||
|
)
|
||||||
|
`, reportKey).Scan(&exists); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !exists && !productPerformanceLiveFallbackEnabled() {
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
return out, exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadProductPerformanceSnapshotItem[T any](ctx context.Context, pg *sql.DB, reportKey string) (T, bool, error) {
|
func loadProductPerformanceSnapshotItem[T any](ctx context.Context, pg *sql.DB, reportKey string) (T, bool, error) {
|
||||||
var zero T
|
var zero T
|
||||||
rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1)
|
rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1)
|
||||||
@@ -721,10 +807,78 @@ ON CONFLICT (report_key) DO UPDATE SET
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveProductPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, reportKey string, def productPerformanceGroupedSnapshotDefinition, rows []map[string]any, started time.Time) error {
|
||||||
|
if pg == nil || strings.TrimSpace(reportKey) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := pg.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key = $1`, reportKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO mk_product_performance_grouped_snapshot (
|
||||||
|
report_key, row_order, mode, group_levels_key, main_group,
|
||||||
|
group_level, group_key, parent_key, group_field, group_value, payload, updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
levelsKey := productPerformanceGroupedLevelsKey(def.Levels)
|
||||||
|
for i, row := range rows {
|
||||||
|
raw, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := stringFromMap(row, "key")
|
||||||
|
if _, err := stmt.ExecContext(
|
||||||
|
ctx,
|
||||||
|
reportKey,
|
||||||
|
i,
|
||||||
|
strings.TrimSpace(def.Mode),
|
||||||
|
levelsKey,
|
||||||
|
strings.TrimSpace(def.MainGroup),
|
||||||
|
intFromMap(row, "level"),
|
||||||
|
key,
|
||||||
|
productPerformanceParentGroupKey(key),
|
||||||
|
stringFromMap(row, "group_field"),
|
||||||
|
stringFromMap(row, "group_value"),
|
||||||
|
raw,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO mk_product_performance_grouped_snapshot_meta (
|
||||||
|
report_key, mode, group_levels_key, main_group, row_count, refreshed_at, duration_ms
|
||||||
|
)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,now(),$6)
|
||||||
|
ON CONFLICT (report_key) DO UPDATE SET
|
||||||
|
mode = EXCLUDED.mode,
|
||||||
|
group_levels_key = EXCLUDED.group_levels_key,
|
||||||
|
main_group = EXCLUDED.main_group,
|
||||||
|
row_count = EXCLUDED.row_count,
|
||||||
|
refreshed_at = EXCLUDED.refreshed_at,
|
||||||
|
duration_ms = EXCLUDED.duration_ms
|
||||||
|
`, reportKey, strings.TrimSpace(def.Mode), levelsKey, strings.TrimSpace(def.MainGroup), len(rows), time.Since(started).Milliseconds()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
||||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
snapshotReadCtx := ctx
|
||||||
ctx = productPerformanceSnapshotBypass(ctx)
|
ctx = productPerformanceSnapshotBypass(ctx)
|
||||||
total := 0
|
total := 0
|
||||||
|
|
||||||
@@ -846,9 +1000,97 @@ func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (
|
|||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
groupedRows, err := RebuildProductPerformanceGroupedSnapshots(snapshotReadCtx, pg)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
total += groupedRows
|
||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type productPerformanceGroupedSnapshotDefinition struct {
|
||||||
|
Mode string
|
||||||
|
Levels []string
|
||||||
|
MainGroup string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
||||||
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defs, err := productPerformanceGroupedSnapshotDefinitions(ctx, pg)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, def := range defs {
|
||||||
|
started := time.Now()
|
||||||
|
levels := sanitizeProductPerformanceGroupLevels(def.Levels)
|
||||||
|
if len(levels) == 0 {
|
||||||
|
levels = defaultProductPerformanceGroupLevels(def.Mode)
|
||||||
|
}
|
||||||
|
sourceRows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, def.Mode, 50000)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
req := ProductPerformanceGroupedRequest{
|
||||||
|
Mode: def.Mode,
|
||||||
|
MainGroup: def.MainGroup,
|
||||||
|
}
|
||||||
|
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
|
||||||
|
rows := make([]map[string]any, 0, len(sourceRows))
|
||||||
|
appendProductPerformanceGroupedRows(&rows, sourceRows, levels, 0, 0, []string{"tab:" + def.Mode}, nil, len(levels)-1)
|
||||||
|
reportKey := productPerformanceGroupedSnapshotReportKey(def.Mode, levels, def.MainGroup)
|
||||||
|
if err := saveProductPerformanceGroupedSnapshotRows(ctx, pg, reportKey, productPerformanceGroupedSnapshotDefinition{
|
||||||
|
Mode: def.Mode,
|
||||||
|
Levels: levels,
|
||||||
|
MainGroup: def.MainGroup,
|
||||||
|
}, rows, started); err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
total += len(rows)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) {
|
||||||
|
defs := []productPerformanceGroupedSnapshotDefinition{
|
||||||
|
{Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}},
|
||||||
|
{Mode: "products", Levels: []string{"urun_ana_grubu"}},
|
||||||
|
{Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||||
|
{Mode: "sales_color_yaka_market_customer", Levels: []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}},
|
||||||
|
{Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}},
|
||||||
|
{Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||||
|
{Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}},
|
||||||
|
{Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
||||||
|
}
|
||||||
|
productRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("products"), 50000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
mainGroups := make([]string, 0)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, row := range productRows {
|
||||||
|
mainGroup := productPerformanceGroupedFilterValue(row, "urun_ana_grubu")
|
||||||
|
if mainGroup == "" || seen[mainGroup] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mainGroup] = true
|
||||||
|
mainGroups = append(mainGroups, mainGroup)
|
||||||
|
}
|
||||||
|
sort.Strings(mainGroups)
|
||||||
|
for _, mainGroup := range mainGroups {
|
||||||
|
defs = append(defs, productPerformanceGroupedSnapshotDefinition{
|
||||||
|
Mode: "product_detail",
|
||||||
|
Levels: []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"},
|
||||||
|
MainGroup: mainGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defs, nil
|
||||||
|
}
|
||||||
|
|
||||||
func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
|
func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
stage := productPerformanceRefreshStage(req.Stage)
|
stage := productPerformanceRefreshStage(req.Stage)
|
||||||
@@ -3472,9 +3714,14 @@ type ProductPerformanceGroupedRequest struct {
|
|||||||
Limit int
|
Limit int
|
||||||
MainGroup string
|
MainGroup string
|
||||||
Filters map[string][]string
|
Filters map[string][]string
|
||||||
|
SortBy string
|
||||||
|
Descending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
||||||
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if req.Limit <= 0 || req.Limit > 50000 {
|
if req.Limit <= 0 || req.Limit > 50000 {
|
||||||
req.Limit = 50000
|
req.Limit = 50000
|
||||||
}
|
}
|
||||||
@@ -3486,6 +3733,13 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
|||||||
req.ExpandThroughLevel = len(levels) - 2
|
req.ExpandThroughLevel = len(levels) - 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if out, ok, err := loadProductPerformancePreparedGroupedRows(ctx, pg, req, levels); ok || err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if !productPerformanceLiveFallbackEnabled() {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -3502,6 +3756,215 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
||||||
|
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
|
||||||
|
if strings.TrimSpace(reportKey) == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
effectiveFilters := productPerformanceGroupedEffectiveFilters(req)
|
||||||
|
hasFilters := len(effectiveFilters) > 0
|
||||||
|
hasManualExpansion := len(req.ExpandedKeys) > 0
|
||||||
|
query := `
|
||||||
|
SELECT payload
|
||||||
|
FROM mk_product_performance_grouped_snapshot
|
||||||
|
WHERE report_key = $1
|
||||||
|
`
|
||||||
|
args := []any{reportKey}
|
||||||
|
if !hasFilters && !hasManualExpansion {
|
||||||
|
maxVisibleLevel := req.ExpandThroughLevel + 1
|
||||||
|
if maxVisibleLevel < 0 {
|
||||||
|
maxVisibleLevel = 0
|
||||||
|
}
|
||||||
|
args = append(args, maxVisibleLevel)
|
||||||
|
query += fmt.Sprintf(" AND group_level <= $%d\n", len(args))
|
||||||
|
}
|
||||||
|
query += "ORDER BY row_order"
|
||||||
|
if !hasFilters && !hasManualExpansion {
|
||||||
|
args = append(args, req.Limit)
|
||||||
|
query += fmt.Sprintf("\nLIMIT $%d", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := pg.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, minInt(req.Limit, 50000))
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var item map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &item); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
item = map[string]any{}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
var exists bool
|
||||||
|
if err := pg.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM mk_product_performance_grouped_snapshot_meta
|
||||||
|
WHERE report_key = $1
|
||||||
|
)
|
||||||
|
`, reportKey).Scan(&exists); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = filterProductPerformancePreparedGroupedRows(out, effectiveFilters)
|
||||||
|
out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
|
||||||
|
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type productPerformanceGroupedSortNode struct {
|
||||||
|
Row map[string]any
|
||||||
|
Children []*productPerformanceGroupedSortNode
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortProductPerformancePreparedGroupedRows(rows []map[string]any, sortBy string, descending bool) []map[string]any {
|
||||||
|
sortBy = strings.TrimSpace(sortBy)
|
||||||
|
if len(rows) == 0 || sortBy == "" {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
roots := make([]*productPerformanceGroupedSortNode, 0)
|
||||||
|
stack := make([]*productPerformanceGroupedSortNode, 0, 16)
|
||||||
|
for _, row := range rows {
|
||||||
|
node := &productPerformanceGroupedSortNode{Row: row}
|
||||||
|
level := intFromMap(row, "level")
|
||||||
|
if level < 0 {
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
if level < len(stack) {
|
||||||
|
stack = stack[:level]
|
||||||
|
}
|
||||||
|
if level > 0 && level-1 < len(stack) {
|
||||||
|
stack[level-1].Children = append(stack[level-1].Children, node)
|
||||||
|
} else {
|
||||||
|
roots = append(roots, node)
|
||||||
|
}
|
||||||
|
if level >= len(stack) {
|
||||||
|
stack = append(stack, node)
|
||||||
|
} else {
|
||||||
|
stack[level] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, len(rows))
|
||||||
|
var appendNodes func(nodes []*productPerformanceGroupedSortNode)
|
||||||
|
appendNodes = func(nodes []*productPerformanceGroupedSortNode) {
|
||||||
|
sort.SliceStable(nodes, func(i, j int) bool {
|
||||||
|
cmp := compareProductPerformanceGroupedSortRows(nodes[i].Row, nodes[j].Row, sortBy)
|
||||||
|
if cmp == 0 {
|
||||||
|
cmp = strings.Compare(strings.ToLower(stringFromMap(nodes[i].Row, "label")), strings.ToLower(stringFromMap(nodes[j].Row, "label")))
|
||||||
|
}
|
||||||
|
if descending {
|
||||||
|
return cmp > 0
|
||||||
|
}
|
||||||
|
return cmp < 0
|
||||||
|
})
|
||||||
|
for _, node := range nodes {
|
||||||
|
out = append(out, node.Row)
|
||||||
|
if len(node.Children) > 0 {
|
||||||
|
appendNodes(node.Children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendNodes(roots)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareProductPerformanceGroupedSortRows(left, right map[string]any, sortBy string) int {
|
||||||
|
leftValue := productPerformanceGroupedSortValue(left, sortBy)
|
||||||
|
rightValue := productPerformanceGroupedSortValue(right, sortBy)
|
||||||
|
leftNum, leftOK := numericProductPerformanceGroupedSortValue(leftValue)
|
||||||
|
rightNum, rightOK := numericProductPerformanceGroupedSortValue(rightValue)
|
||||||
|
if leftOK && rightOK {
|
||||||
|
switch {
|
||||||
|
case leftNum < rightNum:
|
||||||
|
return -1
|
||||||
|
case leftNum > rightNum:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leftText := strings.ToLower(strings.TrimSpace(fmt.Sprint(leftValue)))
|
||||||
|
rightText := strings.ToLower(strings.TrimSpace(fmt.Sprint(rightValue)))
|
||||||
|
return strings.Compare(leftText, rightText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSortValue(row map[string]any, sortBy string) any {
|
||||||
|
sortBy = strings.TrimSpace(sortBy)
|
||||||
|
if sortBy == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value, ok := row[sortBy]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
switch sortBy {
|
||||||
|
case "performance_score_total", "performance_score_90d":
|
||||||
|
return row["performance_score"]
|
||||||
|
case "gross_margin_base_total":
|
||||||
|
return row["gross_margin_total"]
|
||||||
|
case "gross_margin_cost_total":
|
||||||
|
return row["gross_margin_total"]
|
||||||
|
case "color_yaka":
|
||||||
|
return mapGroupValue(row, "color_yaka")
|
||||||
|
case "market_key":
|
||||||
|
return mapGroupValue(row, "market_key")
|
||||||
|
default:
|
||||||
|
if sortBy == stringFromMap(row, "group_field") {
|
||||||
|
return stringFromMap(row, "group_value")
|
||||||
|
}
|
||||||
|
return stringFromMap(row, "label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericProductPerformanceGroupedSortValue(value any) (float64, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return 0, false
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case float32:
|
||||||
|
return float64(v), true
|
||||||
|
case int:
|
||||||
|
return float64(v), true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
case int32:
|
||||||
|
return float64(v), true
|
||||||
|
case json.Number:
|
||||||
|
f, err := v.Float64()
|
||||||
|
return f, err == nil
|
||||||
|
case string:
|
||||||
|
text := strings.TrimSpace(v)
|
||||||
|
if text == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(text, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type productPerformanceSQLGroupFilter struct {
|
type productPerformanceSQLGroupFilter struct {
|
||||||
Field string
|
Field string
|
||||||
Value string
|
Value string
|
||||||
@@ -4018,6 +4481,139 @@ func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[stri
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any {
|
||||||
|
if len(filters) == 0 || len(rows) == 0 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
cleanFilters := map[string]map[string]bool{}
|
||||||
|
for field, values := range filters {
|
||||||
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
||||||
|
if len(cleanValues) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, value := range cleanValues {
|
||||||
|
set[value] = true
|
||||||
|
}
|
||||||
|
cleanFilters[field] = set
|
||||||
|
}
|
||||||
|
if len(cleanFilters) == 0 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedKeys := map[string]bool{}
|
||||||
|
allowedKeys := map[string]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
matches := true
|
||||||
|
for field, allowed := range cleanFilters {
|
||||||
|
if !allowed[productPerformanceGroupedFilterValue(row, field)] {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := stringFromMap(row, "key")
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedKeys[key] = true
|
||||||
|
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
||||||
|
allowedKeys[ancestor] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allowedKeys) == 0 {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
key := stringFromMap(row, "key")
|
||||||
|
if allowedKeys[key] || productPerformanceGroupRowHasMatchedAncestor(key, matchedKeys) {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool {
|
||||||
|
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
||||||
|
if matchedKeys[ancestor] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterProductPerformanceGroupedRowsForExpansion(rows []map[string]any, expandedKeys map[string]bool, expandThroughLevel int, limit int) []map[string]any {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50000 {
|
||||||
|
limit = 50000
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, minInt(limit, len(rows)))
|
||||||
|
for _, row := range rows {
|
||||||
|
if !productPerformanceGroupedRowVisible(row, expandedKeys, expandThroughLevel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedRowVisible(row map[string]any, expandedKeys map[string]bool, expandThroughLevel int) bool {
|
||||||
|
level := intFromMap(row, "level")
|
||||||
|
if level <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
key := stringFromMap(row, "key")
|
||||||
|
if key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ancestors := productPerformanceGroupKeyAncestors(key, false)
|
||||||
|
for ancestorLevel, ancestor := range ancestors {
|
||||||
|
if ancestorLevel <= expandThroughLevel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expandedKeys != nil && expandedKeys[ancestor] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupKeyAncestors(key string, includeSelf bool) []string {
|
||||||
|
parts := strings.Split(strings.TrimSpace(key), "|")
|
||||||
|
if len(parts) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
last := len(parts) - 1
|
||||||
|
if !includeSelf {
|
||||||
|
last--
|
||||||
|
}
|
||||||
|
if last < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, last)
|
||||||
|
for i := 1; i <= last; i++ {
|
||||||
|
out = append(out, strings.Join(parts[:i+1], "|"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceParentGroupKey(key string) string {
|
||||||
|
parts := strings.Split(strings.TrimSpace(key), "|")
|
||||||
|
if len(parts) <= 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(parts[:len(parts)-1], "|")
|
||||||
|
}
|
||||||
|
|
||||||
func productPerformanceGroupedFilterValue(row map[string]any, field string) string {
|
func productPerformanceGroupedFilterValue(row map[string]any, field string) string {
|
||||||
switch field {
|
switch field {
|
||||||
case "urun_ana_grubu":
|
case "urun_ana_grubu":
|
||||||
@@ -4037,6 +4633,12 @@ func productPerformanceGroupedFilterValue(row map[string]any, field string) stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||||
|
if rows, ok, err := productPerformanceGroupedSnapshotRows(ctx, pg, mode, limit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case "products":
|
case "products":
|
||||||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||||
@@ -4062,6 +4664,74 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||||
|
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(mode) == "idle" {
|
||||||
|
return productPerformanceIdleSourceRows(rows), nil
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) {
|
||||||
|
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return rows, exists, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(mode) == "idle" {
|
||||||
|
return productPerformanceIdleSourceRows(rows), true, nil
|
||||||
|
}
|
||||||
|
return rows, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSnapshotReportKey(mode string, levels []string, mainGroup string) string {
|
||||||
|
levels = sanitizeProductPerformanceGroupLevels(levels)
|
||||||
|
if len(levels) == 0 {
|
||||||
|
levels = defaultProductPerformanceGroupLevels(mode)
|
||||||
|
}
|
||||||
|
parts := []string{"grouped", strings.TrimSpace(mode), productPerformanceGroupedLevelsKey(levels)}
|
||||||
|
if strings.TrimSpace(mainGroup) != "" {
|
||||||
|
parts = append(parts, strings.TrimSpace(mainGroup))
|
||||||
|
}
|
||||||
|
return productPerformanceSnapshotKey(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedLevelsKey(levels []string) string {
|
||||||
|
levels = sanitizeProductPerformanceGroupLevels(levels)
|
||||||
|
if len(levels) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(levels, ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSnapshotKey(mode string) (string, bool) {
|
||||||
|
switch strings.TrimSpace(mode) {
|
||||||
|
case "products", "product_detail", "idle", "":
|
||||||
|
return productPerformanceSnapshotKey("products"), true
|
||||||
|
case "order_product_customers":
|
||||||
|
return productPerformanceSnapshotKey("order-product-customers"), true
|
||||||
|
case "order_market_details":
|
||||||
|
return productPerformanceSnapshotKey("order-market-details"), true
|
||||||
|
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||||||
|
return productPerformanceSnapshotKey("sales-breakdown", productPerformanceSalesBreakdownMode(mode)), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func productPerformanceSalesBreakdownMode(mode string) string {
|
func productPerformanceSalesBreakdownMode(mode string) string {
|
||||||
switch strings.TrimSpace(mode) {
|
switch strings.TrimSpace(mode) {
|
||||||
case "sales_color_yaka_market_customer":
|
case "sales_color_yaka_market_customer":
|
||||||
@@ -4293,6 +4963,16 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
|
|||||||
if qty > 0 {
|
if qty > 0 {
|
||||||
out["avg_price_usd_"+suffix] = sales / qty
|
out["avg_price_usd_"+suffix] = sales / qty
|
||||||
}
|
}
|
||||||
|
basePrice, hasBasePrice := productPerformanceUnitCostForSuffix(out, "base_price_usd", suffix)
|
||||||
|
costPrice, hasCostPrice := productPerformanceUnitCostForSuffix(out, "cost_price_usd", suffix)
|
||||||
|
if qty > 0 && hasBasePrice {
|
||||||
|
out["unit_profit_base_"+suffix] = (sales / qty) - basePrice
|
||||||
|
out["gross_profit_base_usd_"+suffix] = sales - (qty * basePrice)
|
||||||
|
}
|
||||||
|
if qty > 0 && hasCostPrice {
|
||||||
|
out["unit_profit_cost_"+suffix] = (sales / qty) - costPrice
|
||||||
|
out["gross_profit_cost_usd_"+suffix] = sales - (qty * costPrice)
|
||||||
|
}
|
||||||
if sales > 0 {
|
if sales > 0 {
|
||||||
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||||||
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||||||
@@ -4321,7 +5001,28 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
|
|||||||
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
|
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
|
||||||
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
||||||
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
|
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
|
||||||
|
out["performance_score_90d"] = productPerformanceSalesPeriodScore(out, "90d")
|
||||||
|
out["performance_score_180d"] = productPerformanceSalesPeriodScore(out, "180d")
|
||||||
|
out["performance_score_365d"] = productPerformanceSalesPeriodScore(out, "365d")
|
||||||
|
out["performance_score_total"] = productPerformanceSalesPeriodScore(out, "total")
|
||||||
out["performance_score"] = productPerformanceGroupScore(out, groupField)
|
out["performance_score"] = productPerformanceGroupScore(out, groupField)
|
||||||
|
if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 {
|
||||||
|
score := productPerformanceOrderGroupScore(out)
|
||||||
|
out["performance_score_90d"] = score
|
||||||
|
out["performance_score_180d"] = score
|
||||||
|
out["performance_score_365d"] = score
|
||||||
|
out["performance_score_total"] = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceUnitCostForSuffix(row map[string]any, baseField, suffix string) (float64, bool) {
|
||||||
|
if value, ok := row[baseField+"_"+suffix]; ok {
|
||||||
|
return floatFromAny(value), true
|
||||||
|
}
|
||||||
|
if value, ok := row[baseField]; ok {
|
||||||
|
return floatFromAny(value), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
||||||
@@ -4344,11 +5045,18 @@ func isProductPerformanceCustomerGroup(groupField string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
||||||
salesIndex := floatFromMap(row, "sales_index_90d")
|
return productPerformanceSalesPeriodScore(row, "90d")
|
||||||
margin := floatFromMap(row, "gross_margin_cost_90d")
|
}
|
||||||
invoiceCount := floatFromMap(row, "invoice_count_90d")
|
|
||||||
salesQty := floatFromMap(row, "sales_qty_90d")
|
func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
|
||||||
stockTurnover := floatFromMap(row, "stock_turnover_90d")
|
salesIndex := floatFromMap(row, "sales_index_"+suffix)
|
||||||
|
margin := floatFromMap(row, "gross_margin_cost_"+suffix)
|
||||||
|
if margin == 0 {
|
||||||
|
margin = floatFromMap(row, "gross_margin_"+suffix)
|
||||||
|
}
|
||||||
|
invoiceCount := floatFromMap(row, "invoice_count_"+suffix)
|
||||||
|
salesQty := floatFromMap(row, "sales_qty_"+suffix)
|
||||||
|
stockTurnover := floatFromMap(row, "stock_turnover_"+suffix)
|
||||||
if stockTurnover == 0 {
|
if stockTurnover == 0 {
|
||||||
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
||||||
stockTurnover = salesQty / stockQty
|
stockTurnover = salesQty / stockQty
|
||||||
@@ -4462,23 +5170,23 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
|||||||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||||
switch mode {
|
switch mode {
|
||||||
case "sales_color_yaka_market_customer":
|
case "sales_color_yaka_market_customer":
|
||||||
return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
||||||
case "product_detail":
|
case "product_detail":
|
||||||
return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"}
|
return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
||||||
case "idle":
|
case "idle":
|
||||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||||
case "sales_product_country_segment_market_customer":
|
case "sales_product_country_segment_market_customer":
|
||||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}
|
||||||
case "sales_market_customer_product":
|
case "sales_market_customer_product":
|
||||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||||
case "sales_country_segment_market_customer_product":
|
case "sales_country_segment_market_customer_product":
|
||||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||||
case "order_product_customers":
|
case "order_product_customers":
|
||||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}
|
||||||
case "order_market_details":
|
case "order_market_details":
|
||||||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"}
|
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
||||||
default:
|
default:
|
||||||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"}
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4671,6 +5379,10 @@ func floatFromMap(row map[string]any, field string) float64 {
|
|||||||
return floatFromAny(row[field])
|
return floatFromAny(row[field])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func intFromMap(row map[string]any, field string) int {
|
||||||
|
return int(floatFromAny(row[field]))
|
||||||
|
}
|
||||||
|
|
||||||
func floatFromAny(value any) float64 {
|
func floatFromAny(value any) float64 {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
@@ -4689,6 +5401,13 @@ func floatFromAny(value any) float64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
||||||
if limit <= 0 || limit > 500 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -270,6 +271,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||||
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
|
mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu"))
|
||||||
|
sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by"))
|
||||||
|
descending := boolQuery(r, "descending", true)
|
||||||
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
|
groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels"))
|
||||||
limit := intQuery(r, "limit", 50000)
|
limit := intQuery(r, "limit", 50000)
|
||||||
expanded := map[string]bool{}
|
expanded := map[string]bool{}
|
||||||
@@ -296,6 +299,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
MainGroup string `json:"urun_ana_grubu"`
|
MainGroup string `json:"urun_ana_grubu"`
|
||||||
Filters map[string][]string `json:"filters"`
|
Filters map[string][]string `json:"filters"`
|
||||||
|
SortBy string `json:"sort_by"`
|
||||||
|
Descending *bool `json:"descending"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest)
|
||||||
@@ -322,6 +327,12 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
if len(body.Filters) > 0 {
|
if len(body.Filters) > 0 {
|
||||||
filters = body.Filters
|
filters = body.Filters
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(body.SortBy) != "" {
|
||||||
|
sortBy = strings.TrimSpace(body.SortBy)
|
||||||
|
}
|
||||||
|
if body.Descending != nil {
|
||||||
|
descending = *body.Descending
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||||
addExpandedKey(key)
|
addExpandedKey(key)
|
||||||
@@ -336,8 +347,11 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
MainGroup: mainGroup,
|
MainGroup: mainGroup,
|
||||||
Filters: filters,
|
Filters: filters,
|
||||||
|
SortBy: sortBy,
|
||||||
|
Descending: descending,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[ProductPerformance][grouped] failed trace_id=%s mode=%s levels=%v expand_through_level=%d filters=%d: %v", traceID, mode, groupLevels, expandThroughLevel, len(filters), err)
|
||||||
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1706,6 +1706,12 @@ const activeSalesBreakdownPagination = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeBackendGroupedSortSignature = computed(() => {
|
||||||
|
if (!backendGroupedSupportedTab(activeTab.value)) return ''
|
||||||
|
const state = tablePagination[activeTab.value] || {}
|
||||||
|
return `${activeTab.value}|${state.sortBy || ''}|${state.descending !== false ? 1 : 0}`
|
||||||
|
})
|
||||||
|
|
||||||
function normalizeTablePagination (value) {
|
function normalizeTablePagination (value) {
|
||||||
return {
|
return {
|
||||||
page: Number(value?.page || 1),
|
page: Number(value?.page || 1),
|
||||||
@@ -3014,6 +3020,7 @@ function groupedBucketSortValue (sourceRows, sortBy) {
|
|||||||
|
|
||||||
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
|
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
|
||||||
const source = Array.isArray(tableRows) ? tableRows : []
|
const source = Array.isArray(tableRows) ? tableRows : []
|
||||||
|
if (backendGroupedSupportedTab(activeTab.value)) return source
|
||||||
if (!sortBy || !source.some(row => row?.__group)) {
|
if (!sortBy || !source.some(row => row?.__group)) {
|
||||||
return sortFlatTableRows(source, sortBy, descending)
|
return sortFlatTableRows(source, sortBy, descending)
|
||||||
}
|
}
|
||||||
@@ -4619,14 +4626,17 @@ async function loadBackendGroupedRows (options = {}) {
|
|||||||
backendGroupedLoading.value = true
|
backendGroupedLoading.value = true
|
||||||
const filtersKey = JSON.stringify(filterState.filters)
|
const filtersKey = JSON.stringify(filterState.filters)
|
||||||
const expandThroughLevel = selectedExpandThroughLevel.value
|
const expandThroughLevel = selectedExpandThroughLevel.value
|
||||||
const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}`
|
const sortState = tableSortState(tabKey)
|
||||||
|
const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}|${sortState.sortBy}|${sortState.descending ? 1 : 0}`
|
||||||
const params = {
|
const params = {
|
||||||
mode: tabKey,
|
mode: tabKey,
|
||||||
groupLevels: activeGroupLevels.value.map(level => level.key),
|
groupLevels: activeGroupLevels.value.map(level => level.key),
|
||||||
expandedKeys: activeExpandedGroupKeys(),
|
expandedKeys: activeExpandedGroupKeys(),
|
||||||
expandThroughLevel,
|
expandThroughLevel,
|
||||||
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000,
|
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000,
|
||||||
filters: filterState.filters
|
filters: filterState.filters,
|
||||||
|
sortBy: sortState.sortBy,
|
||||||
|
descending: sortState.descending
|
||||||
}
|
}
|
||||||
if (tabKey === 'product_detail') {
|
if (tabKey === 'product_detail') {
|
||||||
params.urunAnaGrubu = selectedDetailMainGroup.value
|
params.urunAnaGrubu = selectedDetailMainGroup.value
|
||||||
@@ -4635,7 +4645,8 @@ async function loadBackendGroupedRows (options = {}) {
|
|||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
|
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
|
||||||
const currentFilterState = backendGroupedFilterState(activeTab.value)
|
const currentFilterState = backendGroupedFilterState(activeTab.value)
|
||||||
const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}`
|
const currentSortState = tableSortState(activeTab.value)
|
||||||
|
const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}|${currentSortState.sortBy}|${currentSortState.descending ? 1 : 0}`
|
||||||
if (requestKey !== currentKey) return
|
if (requestKey !== currentKey) return
|
||||||
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
|
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
|
||||||
backendGroupedRows.value = {
|
backendGroupedRows.value = {
|
||||||
@@ -4657,17 +4668,7 @@ async function loadBackendGroupedRows (options = {}) {
|
|||||||
function normalizeGroupedDisplayRow (tabKey, row) {
|
function normalizeGroupedDisplayRow (tabKey, row) {
|
||||||
const nextRow = { ...row }
|
const nextRow = { ...row }
|
||||||
normalizeGroupedProductHierarchyValues(nextRow)
|
normalizeGroupedProductHierarchyValues(nextRow)
|
||||||
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') {
|
return nextRow
|
||||||
return withOrderPeriodScores(nextRow)
|
|
||||||
}
|
|
||||||
const next = withPeriodScores(withGeneralMargins(withProductMargins(nextRow)))
|
|
||||||
if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') {
|
|
||||||
next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d')))
|
|
||||||
next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d')))
|
|
||||||
next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d')))
|
|
||||||
next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total')))
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGroupedProductHierarchyValues (row) {
|
function normalizeGroupedProductHierarchyValues (row) {
|
||||||
@@ -5048,6 +5049,11 @@ watch(activeTab, () => {
|
|||||||
setupTopScrollbarSync()
|
setupTopScrollbarSync()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(activeBackendGroupedSortSignature, () => {
|
||||||
|
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
|
})
|
||||||
|
|
||||||
watch(activeDisplayedTableRows, () => {
|
watch(activeDisplayedTableRows, () => {
|
||||||
setupTopScrollbarSync()
|
setupTopScrollbarSync()
|
||||||
}, { flush: 'post' })
|
}, { flush: 'post' })
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
|||||||
expandedKeys: normalizedList(params.expandedKeys, true),
|
expandedKeys: normalizedList(params.expandedKeys, true),
|
||||||
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||||
filters: normalizedFilterMap(params.filters),
|
filters: normalizedFilterMap(params.filters),
|
||||||
|
sortBy: String(params.sortBy || params.sort_by || ''),
|
||||||
|
descending: params.descending !== false,
|
||||||
limit: Number(params.limit || 0)
|
limit: Number(params.limit || 0)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -134,13 +136,17 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
|||||||
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean),
|
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean),
|
||||||
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||||
filters: normalizedFilterMap(params.filters),
|
filters: normalizedFilterMap(params.filters),
|
||||||
|
sort_by: String(params.sortBy || params.sort_by || ''),
|
||||||
|
descending: params.descending !== false,
|
||||||
limit: params.limit
|
limit: params.limit
|
||||||
}, {
|
}, {
|
||||||
params: {
|
params: {
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
|
urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '',
|
||||||
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1)
|
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
|
||||||
|
sort_by: String(params.sortBy || params.sort_by || ''),
|
||||||
|
descending: params.descending !== false
|
||||||
},
|
},
|
||||||
timeout: 90000
|
timeout: 90000
|
||||||
}).then(resp => {
|
}).then(resp => {
|
||||||
@@ -154,10 +160,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store
|
|||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.warn(`${logPrefix} request failed`, {
|
const message = err?.response?.data || err?.message || String(err || '')
|
||||||
|
console.warn(`${logPrefix} request failed: ${message}`, {
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
|
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
|
||||||
message: err?.response?.data || err?.message || err
|
message
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user