Fix product performance grouped JSON build
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -581,6 +582,35 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta (
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
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 {
|
||||
@@ -669,6 +699,62 @@ SELECT EXISTS (
|
||||
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) {
|
||||
var zero T
|
||||
rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1)
|
||||
@@ -721,10 +807,78 @@ ON CONFLICT (report_key) DO UPDATE SET
|
||||
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) {
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
snapshotReadCtx := ctx
|
||||
ctx = productPerformanceSnapshotBypass(ctx)
|
||||
total := 0
|
||||
|
||||
@@ -846,9 +1000,97 @@ func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
groupedRows, err := RebuildProductPerformanceGroupedSnapshots(snapshotReadCtx, pg)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += groupedRows
|
||||
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) {
|
||||
started := time.Now()
|
||||
stage := productPerformanceRefreshStage(req.Stage)
|
||||
@@ -3472,9 +3714,14 @@ type ProductPerformanceGroupedRequest struct {
|
||||
Limit int
|
||||
MainGroup string
|
||||
Filters map[string][]string
|
||||
SortBy string
|
||||
Descending bool
|
||||
}
|
||||
|
||||
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 {
|
||||
req.Limit = 50000
|
||||
}
|
||||
@@ -3486,6 +3733,13 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -3502,6 +3756,215 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
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 {
|
||||
Field string
|
||||
Value string
|
||||
@@ -4018,6 +4481,139 @@ func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[stri
|
||||
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 {
|
||||
switch field {
|
||||
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) {
|
||||
if rows, ok, err := productPerformanceGroupedSnapshotRows(ctx, pg, mode, limit); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "products":
|
||||
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 {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "sales_color_yaka_market_customer":
|
||||
@@ -4293,6 +4963,16 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string)
|
||||
if qty > 0 {
|
||||
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 _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||||
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_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
||||
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)
|
||||
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 {
|
||||
@@ -4344,11 +5045,18 @@ func isProductPerformanceCustomerGroup(groupField string) bool {
|
||||
}
|
||||
|
||||
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
||||
salesIndex := floatFromMap(row, "sales_index_90d")
|
||||
margin := floatFromMap(row, "gross_margin_cost_90d")
|
||||
invoiceCount := floatFromMap(row, "invoice_count_90d")
|
||||
salesQty := floatFromMap(row, "sales_qty_90d")
|
||||
stockTurnover := floatFromMap(row, "stock_turnover_90d")
|
||||
return productPerformanceSalesPeriodScore(row, "90d")
|
||||
}
|
||||
|
||||
func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
|
||||
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 stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
||||
stockTurnover = salesQty / stockQty
|
||||
@@ -4462,23 +5170,23 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||
switch mode {
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
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])
|
||||
}
|
||||
|
||||
func intFromMap(row map[string]any, field string) int {
|
||||
return int(floatFromAny(row[field]))
|
||||
}
|
||||
|
||||
func floatFromAny(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
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) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
|
||||
Reference in New Issue
Block a user