Fix product performance grouped JSON build

This commit is contained in:
M_Kececi
2026-07-05 15:29:59 +03:00
parent c602e1e119
commit 7879172c54
4 changed files with 777 additions and 31 deletions
+733 -14
View File
@@ -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
+14
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
@@ -270,6 +271,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
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"))
limit := intQuery(r, "limit", 50000)
expanded := map[string]bool{}
@@ -296,6 +299,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
Limit int `json:"limit"`
MainGroup string `json:"urun_ana_grubu"`
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 {
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 {
filters = body.Filters
}
if strings.TrimSpace(body.SortBy) != "" {
sortBy = strings.TrimSpace(body.SortBy)
}
if body.Descending != nil {
descending = *body.Descending
}
} else {
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
addExpandedKey(key)
@@ -336,8 +347,11 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
Limit: limit,
MainGroup: mainGroup,
Filters: filters,
SortBy: sortBy,
Descending: descending,
})
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)
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) {
return {
page: Number(value?.page || 1),
@@ -3014,6 +3020,7 @@ function groupedBucketSortValue (sourceRows, sortBy) {
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : []
if (backendGroupedSupportedTab(activeTab.value)) return source
if (!sortBy || !source.some(row => row?.__group)) {
return sortFlatTableRows(source, sortBy, descending)
}
@@ -4619,14 +4626,17 @@ async function loadBackendGroupedRows (options = {}) {
backendGroupedLoading.value = true
const filtersKey = JSON.stringify(filterState.filters)
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 = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
expandThroughLevel,
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') {
params.urunAnaGrubu = selectedDetailMainGroup.value
@@ -4635,7 +4645,8 @@ async function loadBackendGroupedRows (options = {}) {
const startedAt = performance.now()
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
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
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
backendGroupedRows.value = {
@@ -4657,17 +4668,7 @@ async function loadBackendGroupedRows (options = {}) {
function normalizeGroupedDisplayRow (tabKey, row) {
const nextRow = { ...row }
normalizeGroupedProductHierarchyValues(nextRow)
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') {
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
return nextRow
}
function normalizeGroupedProductHierarchyValues (row) {
@@ -5048,6 +5049,11 @@ watch(activeTab, () => {
setupTopScrollbarSync()
})
watch(activeBackendGroupedSortSignature, () => {
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
scheduleLoadBackendGroupedRows()
})
watch(activeDisplayedTableRows, () => {
setupTopScrollbarSync()
}, { flush: 'post' })
+10 -3
View File
@@ -90,6 +90,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store
expandedKeys: normalizedList(params.expandedKeys, true),
expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
filters: normalizedFilterMap(params.filters),
sortBy: String(params.sortBy || params.sort_by || ''),
descending: params.descending !== false,
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),
expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1),
filters: normalizedFilterMap(params.filters),
sort_by: String(params.sortBy || params.sort_by || ''),
descending: params.descending !== false,
limit: params.limit
}, {
params: {
mode: params.mode,
limit: params.limit,
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
}).then(resp => {
@@ -154,10 +160,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store
})
return rows
}).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,
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
message: err?.response?.data || err?.message || err
message
})
throw err
}).finally(() => {