Fix product performance grouped JSON build

This commit is contained in:
M_Kececi
2026-07-05 18:31:40 +03:00
parent 28e33d5e58
commit b2113a8d49
8 changed files with 1207 additions and 252 deletions
+735 -108
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"os"
"sort"
"strconv"
@@ -372,10 +373,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily (
UPDATE mk_product_performance_sales_daily
SET urun_ilk_grubu = '', updated_at = now()
WHERE btrim(urun_ilk_grubu) = '-'
OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
`
DELETE FROM mk_product_performance_sales_daily
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
`
UPDATE mk_product_performance_sales_daily
SET askili_yan = '', updated_at = now()
@@ -452,7 +453,7 @@ WHERE cost_price_usd > 0
AND cost_price_usd < base_price_usd`,
`
DELETE FROM mk_product_performance_price_dim
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
`
CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
kpi_date DATE NOT NULL,
@@ -519,10 +520,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
UPDATE mk_product_performance_kpi_daily
SET urun_ilk_grubu = '', updated_at = now()
WHERE btrim(urun_ilk_grubu) = '-'
OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
`
DELETE FROM mk_product_performance_kpi_daily
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`,
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
`
UPDATE mk_product_performance_kpi_daily
SET
@@ -602,6 +603,7 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot (
)`,
`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 INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_field ON mk_product_performance_grouped_snapshot (report_key, group_field, group_value)`,
`
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta (
report_key TEXT PRIMARY KEY,
@@ -1754,6 +1756,7 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceRow](ctx, pg, productPerformanceSnapshotKey("products"), limit); err != nil {
return nil, 0, err
} else if ok {
applyProductPerformanceProductRowScores(rows)
return rows, len(rows), nil
}
}
@@ -1819,9 +1822,44 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
}
applyProductPerformanceProductRowScores(out)
return out, total, nil
}
func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) {
avg := productPerformanceProductRowAverages(rows)
for i := range rows {
rows[i].PerformanceScore = productPerformanceProductScore(
"90d",
rows[i].SalesUSD90,
productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90),
rows[i].GrossMargin90,
rows[i].StockTurnover90,
float64(rows[i].MarketCount90),
float64(rows[i].CustomerCount90),
)
}
}
type productPerformanceProductRowAverage struct {
salesUSD90 float64
}
func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage {
var sum90, count90 float64
for _, row := range rows {
if row.SalesUSD90 > 0 {
sum90 += row.SalesUSD90
count90++
}
}
out := productPerformanceProductRowAverage{}
if count90 > 0 {
out.salesUSD90 = sum90 / count90
}
return out
}
func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return models.ProductPerformanceSummary{}, err
@@ -1841,7 +1879,7 @@ KPI AS (
SELECT *
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
),
VariantStock AS (
SELECT
@@ -1868,7 +1906,7 @@ Sales90 AS (
COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d
FROM mk_product_performance_sales_daily
WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
)
SELECT
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
@@ -1904,6 +1942,7 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) (
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceGeneralRow](ctx, pg, productPerformanceSnapshotKey("general"), limit); err != nil {
return nil, err
} else if ok {
applyProductPerformanceGeneralRowScores(rows)
return rows, nil
}
rows, err := pg.QueryContext(ctx, `
@@ -1943,7 +1982,7 @@ SalesAgg AS (
COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total
FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY product_code, color_code, yaka_kodu, market_key
),
StockAgg AS (
@@ -1995,7 +2034,7 @@ Dim AS (
1 AS src_rank
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
UNION ALL
SELECT
product_code,
@@ -2011,7 +2050,7 @@ Dim AS (
MAX(urun_alt_grubu) AS urun_alt_grubu,
2 AS src_rank
FROM mk_product_performance_sales_daily
WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY product_code, color_code, yaka_kodu
) x
ORDER BY product_code, color_code, yaka_kodu, src_rank
@@ -2043,7 +2082,7 @@ StockOnly AS (
AND d.color_code = s.color_code
AND d.yaka_kodu = s.yaka_kodu
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
AND upper(btrim(COALESCE(d.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(d.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
AND NOT EXISTS (
SELECT 1
FROM SalesAgg a
@@ -2067,7 +2106,7 @@ Spread AS (
COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all
FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY product_code, color_code, yaka_kodu
),
Scored AS (
@@ -2199,9 +2238,28 @@ LIMIT $1
for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
}
applyProductPerformanceGeneralRowScores(out)
return out, nil
}
func applyProductPerformanceGeneralRowScores(rows []models.ProductPerformanceGeneralRow) {
for i := range rows {
turnover := 0.0
if rows[i].StockQty > 0 {
turnover = rows[i].SalesQtyTotal / rows[i].StockQty
}
rows[i].PerformanceScore = productPerformanceProductScore(
"total",
rows[i].SalesUSDTotal,
rows[i].SalesIndexTotal,
rows[i].GrossMarginTotal,
turnover,
float64(rows[i].MarketCountTotal),
float64(rows[i].CustomerCountTotal),
)
}
}
func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
@@ -3061,7 +3119,7 @@ SELECT
COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY market_key
ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC
LIMIT $1
@@ -3122,7 +3180,7 @@ SELECT
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d
FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days'
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY customer_country, customer_segment, market_key
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1
@@ -3207,7 +3265,7 @@ SELECT
FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days'
AND COALESCE(customer_code,'') <> ''
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY %s
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1
@@ -3250,7 +3308,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
"item_description": "MAX(s.item_description)",
"kategori": "COALESCE(MAX(s.kategori),'')",
"askili_yan": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END),'')",
"urun_ilk_grubu": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END),'')",
"urun_ilk_grubu": "COALESCE(MAX(" + productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu") + "),'')",
"urun_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')",
"urun_alt_grubu": "MAX(s.urun_alt_grubu)",
"market_key": "s.market_key",
@@ -3277,7 +3335,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
selected["market_key"] = true
selected["customer_code"] = true
selected["customer_name"] = true
groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
case "product_country_segment_market_customer":
selected["product_code"] = true
selected["item_description"] = true
@@ -3340,12 +3398,13 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break
selected["market_key"] = true
selected["customer_code"] = true
selected["customer_name"] = true
groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
}
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceSalesBreakdownRow](ctx, pg, productPerformanceSnapshotKey("sales-breakdown", mode), limit); err != nil {
return nil, err
} else if ok {
applyProductPerformanceSalesBreakdownScores(rows)
return rows, nil
}
@@ -3366,14 +3425,14 @@ WITH LatestKPI AS (
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
CASE
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
ELSE COALESCE(urun_ilk_grubu,'')
END AS urun_ilk_grubu,
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
urun_alt_grubu
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
),
StockAgg AS (
@@ -3444,6 +3503,14 @@ Agg AS (
%s,
%s,
COUNT(DISTINCT product_code) AS product_count,
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS product_group_count_90d,
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS product_group_count_180d,
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS product_group_count_365d,
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS product_group_count_total,
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS market_count_90d,
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS market_count_180d,
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS market_count_365d,
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS market_count_total,
COALESCE(MAX(stock_qty),0) AS stock_qty,
COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d,
COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d,
@@ -3534,7 +3601,7 @@ Agg AS (
LEFT JOIN StockTotalStart stotal ON stotal.product_code = s.product_code AND stotal.color_code = s.color_code AND stotal.yaka_kodu = s.yaka_kodu
) s
WHERE sales_date >= DATE '2022-01-01'
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY %s
),
Enriched AS (
@@ -3584,6 +3651,14 @@ SELECT
customer_code,
customer_name,
product_count,
product_group_count_90d,
product_group_count_180d,
product_group_count_365d,
product_group_count_total,
market_count_90d,
market_count_180d,
market_count_365d,
market_count_total,
stock_qty,
customer_count_90d,
invoice_count_90d,
@@ -3690,8 +3765,8 @@ SELECT
ELSE 'TAKIP'
END AS performance_bucket,
CASE
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Güçlü satış kırılımı'
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gün zayıf, kontrol et'
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Guclu satis kirilimi'
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gun zayif, kontrol et'
ELSE 'Takip'
END AS recommendation,
last_sale_date
@@ -3729,7 +3804,9 @@ LIMIT $1
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
&r.ProductCount, &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
&r.ProductCount, &r.ProductGroupCount90, &r.ProductGroupCount180, &r.ProductGroupCount365, &r.ProductGroupCountTotal,
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
&r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
&r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180,
@@ -3755,9 +3832,52 @@ LIMIT $1
for i := range out {
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
}
applyProductPerformanceSalesBreakdownScores(out)
return out, nil
}
func applyProductPerformanceSalesBreakdownScores(rows []models.ProductPerformanceSalesBreakdownRow) {
avg90 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD90 })
avg180 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD180 })
avg365 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD365 })
avgTotal := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSDTotal })
for i := range rows {
r := &rows[i]
r.SalesIndex90 = productPerformanceRelativeIndex(r.SalesUSD90, avg90)
r.SalesIndex180 = productPerformanceRelativeIndex(r.SalesUSD180, avg180)
r.SalesIndex365 = productPerformanceRelativeIndex(r.SalesUSD365, avg365)
r.SalesIndexTotal = productPerformanceRelativeIndex(r.SalesUSDTotal, avgTotal)
r.CustomerScore90 = productPerformanceCustomerScore("90d", r.SalesUSD90, r.GrossMarginCost90, float64(r.ProductGroupCount90), r.SalesQty90)
r.CustomerScore180 = productPerformanceCustomerScore("180d", r.SalesUSD180, r.GrossMarginCost180, float64(r.ProductGroupCount180), r.SalesQty180)
r.CustomerScore365 = productPerformanceCustomerScore("365d", r.SalesUSD365, r.GrossMarginCost365, float64(r.ProductGroupCount365), r.SalesQty365)
r.CustomerScoreTotal = productPerformanceCustomerScore("total", r.SalesUSDTotal, r.GrossMarginCostTotal, float64(r.ProductGroupCountTotal), r.SalesQtyTotal)
r.PerformanceScore90 = productPerformanceProductScore("90d", r.SalesUSD90, r.SalesIndex90, r.GrossMarginCost90, r.StockTurnover90, float64(r.MarketCount90), float64(r.CustomerCount90))
r.PerformanceScore180 = productPerformanceProductScore("180d", r.SalesUSD180, r.SalesIndex180, r.GrossMarginCost180, r.StockTurnover180, float64(r.MarketCount180), float64(r.CustomerCount180))
r.PerformanceScore365 = productPerformanceProductScore("365d", r.SalesUSD365, r.SalesIndex365, r.GrossMarginCost365, r.StockTurnover365, float64(r.MarketCount365), float64(r.CustomerCount365))
r.PerformanceScoreTotal = productPerformanceProductScore("total", r.SalesUSDTotal, r.SalesIndexTotal, r.GrossMarginCostTotal, r.StockTurnoverTotal, float64(r.MarketCountTotal), float64(r.CustomerCountTotal))
r.PerformanceScore = r.PerformanceScore90
}
}
func productPerformanceSalesBreakdownAverage(rows []models.ProductPerformanceSalesBreakdownRow, value func(models.ProductPerformanceSalesBreakdownRow) float64) float64 {
var sum, count float64
for _, row := range rows {
v := value(row)
if v <= 0 {
continue
}
sum += v
count++
}
if count == 0 {
return 0
}
return sum / count
}
type ProductPerformanceGroupedRequest struct {
Mode string
GroupLevels []string
@@ -3770,6 +3890,14 @@ type ProductPerformanceGroupedRequest struct {
Descending bool
}
type ProductPerformanceGroupedFilterOptionsRequest struct {
Mode string
GroupLevels []string
MainGroup string
Fields []string
Limit int
}
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
@@ -3805,12 +3933,114 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
return out, nil
}
func ListProductPerformanceGroupedFilterOptions(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedFilterOptionsRequest) (map[string][]string, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if req.Limit <= 0 || req.Limit > 5000 {
req.Limit = 5000
}
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
if len(levels) == 0 {
levels = defaultProductPerformanceGroupLevels(req.Mode)
}
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
if strings.TrimSpace(reportKey) == "" {
return map[string][]string{}, nil
}
levelSet := map[string]bool{}
for _, level := range levels {
levelSet[level] = true
}
out := map[string][]string{}
for _, field := range req.Fields {
field = strings.TrimSpace(field)
if !productPerformanceGroupedFilterOptionFieldAllowed(field) {
continue
}
values, err := productPerformanceGroupedSnapshotFilterOptions(ctx, pg, reportKey, field, levelSet, strings.TrimSpace(req.MainGroup), req.Limit)
if err != nil {
return nil, err
}
out[field] = values
}
return out, nil
}
func productPerformanceGroupedFilterOptionFieldAllowed(field string) bool {
switch field {
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu",
"product_code", "color_yaka", "market_key", "country", "customer_segment",
"customer_code", "customer_name", "performance_bucket":
return true
default:
return false
}
}
func productPerformanceGroupedSnapshotFilterOptions(ctx context.Context, pg *sql.DB, reportKey, field string, levelSet map[string]bool, mainGroup string, limit int) ([]string, error) {
if field == "urun_ana_grubu" && strings.Contains(reportKey, ":product_detail:") && strings.TrimSpace(mainGroup) != "" {
return []string{strings.TrimSpace(mainGroup)}, nil
}
if field == "performance_bucket" {
return productPerformanceGroupedSnapshotPayloadOptions(ctx, pg, reportKey, "performance_bucket", limit)
}
if !levelSet[field] {
return []string{}, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT btrim(group_value) AS value
FROM mk_product_performance_grouped_snapshot
WHERE report_key = $1
AND group_field = $2
AND btrim(group_value) <> ''
ORDER BY value
LIMIT $3
`, reportKey, field, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProductPerformanceGroupedFilterOptionRows(rows)
}
func productPerformanceGroupedSnapshotPayloadOptions(ctx context.Context, pg *sql.DB, reportKey, field string, limit int) ([]string, error) {
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT btrim(payload ->> $2) AS value
FROM mk_product_performance_grouped_snapshot
WHERE report_key = $1
AND btrim(payload ->> $2) <> ''
ORDER BY value
LIMIT $3
`, reportKey, field, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProductPerformanceGroupedFilterOptionRows(rows)
}
func scanProductPerformanceGroupedFilterOptionRows(rows *sql.Rows) ([]string, error) {
out := []string{}
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
return nil, err
}
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out, rows.Err()
}
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)
effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req)
hasFilters := len(effectiveFilters) > 0
hasManualExpansion := len(req.ExpandedKeys) > 0
query := `
@@ -4065,6 +4295,21 @@ func productPerformanceGroupedEffectiveFilters(req ProductPerformanceGroupedRequ
return filters
}
func productPerformancePreparedGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string {
filters := make(map[string][]string, len(req.Filters))
for field, values := range req.Filters {
if strings.TrimSpace(req.Mode) == "product_detail" && field == "urun_ana_grubu" {
continue
}
cleanValues := cleanProductPerformanceFilterValues(values)
if len(cleanValues) == 0 {
continue
}
filters[field] = cleanValues
}
return filters
}
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, expandThroughLevel int, limit int) error {
if level >= len(levels) {
return nil
@@ -4371,7 +4616,7 @@ Source AS (
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
CASE
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
ELSE COALESCE(urun_ilk_grubu,'')
END AS urun_ilk_grubu,
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
@@ -4418,14 +4663,14 @@ Source AS (
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')` + idleWhere + `
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')` + idleWhere + `
)`
}
func productPerformanceSQLGroupExpr(field string) (string, bool) {
switch field {
case "urun_ilk_grubu":
return "CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END", true
return productPerformanceCleanFirstGroupSQL("urun_ilk_grubu"), true
case "askili_yan":
return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true
case "urun_ana_grubu":
@@ -4550,23 +4795,23 @@ func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters
return rows
}
rowByKey := make(map[string]map[string]any, len(rows))
for _, row := range rows {
if key := stringFromMap(row, "key"); key != "" {
rowByKey[key] = row
}
}
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
}
if !productPerformancePreparedGroupedPathMatches(key, rowByKey, cleanFilters) {
continue
}
matchedKeys[key] = true
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
allowedKeys[ancestor] = true
@@ -4585,6 +4830,49 @@ func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters
return out
}
func productPerformancePreparedGroupedPathMatches(key string, rowByKey map[string]map[string]any, filters map[string]map[string]bool) bool {
pathKeys := productPerformanceGroupKeyAncestors(key, true)
if len(pathKeys) == 0 {
pathKeys = []string{key}
}
for field, allowed := range filters {
matched := false
for _, pathKey := range pathKeys {
row := rowByKey[pathKey]
if row == nil {
continue
}
if allowed[productPerformancePreparedGroupedFilterValue(row, field)] {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
func productPerformancePreparedGroupedFilterValue(row map[string]any, field string) string {
field = strings.TrimSpace(field)
groupField := strings.TrimSpace(stringFromMap(row, "group_field"))
if groupField == field {
return normalizeProductPerformanceGroupValue(stringFromMap(row, "group_value"))
}
switch field {
case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan":
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
if value != "" {
return value
}
}
if value := normalizeProductPerformanceGroupValue(stringFromMap(row, field)); value != "" {
return value
}
return ""
}
func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool {
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
if matchedKeys[ancestor] {
@@ -4728,9 +5016,61 @@ func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql
if strings.TrimSpace(mode) == "idle" {
return productPerformanceIdleSourceRows(rows), nil
}
if mode == "products" || mode == "product_detail" {
rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows)
}
return rows, nil
}
func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
if len(rows) == 0 {
return rows
}
generalRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("general"), 50000)
if err != nil || !ok || len(generalRows) == 0 {
return rows
}
byKey := make(map[string]map[string]any, len(generalRows))
for _, row := range generalRows {
key := productPerformanceMapMarketVariantKey(row)
if key != "" {
byKey[key] = row
}
}
for _, row := range rows {
general := byKey[productPerformanceMapMarketVariantKey(row)]
if general == nil {
continue
}
for _, field := range []string{
"market_count_total", "customer_count_total", "invoice_count_total", "sales_index_total",
"avg_price_usd_total", "gross_profit_usd_total", "gross_margin_total",
"unit_profit_cost_total", "unit_profit_base_total", "first_sale_date",
} {
if value, ok := general[field]; ok {
row[field] = value
}
}
if value, ok := general["performance_score"]; ok {
row["performance_score_total"] = value
}
}
return rows
}
func productPerformanceMapMarketVariantKey(row map[string]any) string {
productCode := normalizeProductPerformanceProductCode(stringFromMap(row, "product_code"))
if productCode == "" {
return ""
}
return strings.Join([]string{
productCode,
strings.TrimSpace(stringFromMap(row, "color_code")),
strings.TrimSpace(stringFromMap(row, "yaka_kodu")),
displayProductPerformanceMarketName(stringFromMap(row, "market_key")),
}, "|")
}
func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) {
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
if !ok {
@@ -4823,19 +5163,23 @@ type productPerformanceGroupedSnapshotAvgState struct {
}
type productPerformanceGroupedSnapshotNode struct {
Key string
Level int
Field string
Value string
Row map[string]any
Count int
Children map[string]*productPerformanceGroupedSnapshotNode
ChildOrder []string
StockSeen map[string]bool
IdleSeen map[string]bool
Avg map[string]*productPerformanceGroupedSnapshotAvgState
BucketCounts map[string]int
Image map[string]any
Key string
Level int
Field string
Value string
Row map[string]any
Count int
Children map[string]*productPerformanceGroupedSnapshotNode
ChildOrder []string
StockSeen map[string]bool
IdleSeen map[string]bool
Avg map[string]*productPerformanceGroupedSnapshotAvgState
BucketCounts map[string]int
Image map[string]any
Market90Seen map[string]bool
MarketTotalSeen map[string]bool
Customer90Seen map[string]bool
CustomerTotalSeen map[string]bool
}
func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any {
@@ -4921,8 +5265,6 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
n.IdleSeen[variantKey] = true
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
}
case shouldSumProductPerformanceField(key):
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
case shouldAverageProductPerformanceField(key):
if n.Avg == nil {
n.Avg = map[string]*productPerformanceGroupedSnapshotAvgState{}
@@ -4940,12 +5282,48 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
}
state.Sum += number
state.Count++
case shouldSumProductPerformanceField(key):
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
default:
if _, ok := n.Row[key]; !ok {
n.Row[key] = value
}
}
}
n.addDistinctSpread(row)
}
func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) {
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
if market != "" && market != "STOK" {
if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 {
if n.Market90Seen == nil {
n.Market90Seen = map[string]bool{}
}
n.Market90Seen[market] = true
}
if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 {
if n.MarketTotalSeen == nil {
n.MarketTotalSeen = map[string]bool{}
}
n.MarketTotalSeen[market] = true
}
}
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
if customer != "" && customer != "-" {
if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 {
if n.Customer90Seen == nil {
n.Customer90Seen = map[string]bool{}
}
n.Customer90Seen[customer] = true
}
if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 {
if n.CustomerTotalSeen == nil {
n.CustomerTotalSeen = map[string]bool{}
}
n.CustomerTotalSeen[customer] = true
}
}
}
func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) {
@@ -4985,6 +5363,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any {
if bucket := n.dominantBucket(); bucket != "" {
row["performance_bucket"] = bucket
}
applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen)
clearProductPerformanceGroupDimensions(row, n.Field, n.Value)
row["__group"] = true
row["row_key"] = "group|" + n.Key
@@ -4994,7 +5373,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any {
row["group_value"] = n.Value
row["label"] = n.Value
row["count"] = n.Count
row["recommendation"] = fmt.Sprintf("%d satir", n.Count)
row["recommendation"] = productPerformanceGroupRecommendation(row, n.Field, n.Count)
image := n.Image
if image == nil {
image = row
@@ -5063,7 +5442,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string
row["group_value"] = value
row["label"] = value
row["count"] = len(rows)
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
row["recommendation"] = productPerformanceGroupRecommendation(row, field, len(rows))
image := firstProductPerformanceImageSource(rows)
row["image_product_code"] = stringFromMap(image, "product_code")
@@ -5101,7 +5480,12 @@ func clearProductPerformanceGroupDimensions(row map[string]any, groupField, grou
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
out := map[string]any{}
market90Seen := map[string]bool{}
marketTotalSeen := map[string]bool{}
customer90Seen := map[string]bool{}
customerTotalSeen := map[string]bool{}
for _, row := range rows {
addProductPerformanceDistinctSpread(row, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen)
for key, value := range row {
if key == "row_key" || key == "key" {
continue
@@ -5117,9 +5501,12 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m
out[key] = distinctProductPerformanceVariantStockCost(rows)
continue
}
if shouldAverageProductPerformanceField(key) {
continue
}
if shouldSumProductPerformanceField(key) {
out[key] = floatFromAny(out[key]) + floatFromAny(value)
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
} else if _, ok := out[key]; !ok {
out[key] = value
}
}
@@ -5136,9 +5523,96 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m
}
deriveProductPerformanceGroupMetrics(out, groupField)
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen)
return out
}
func addProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) {
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
if market != "" && market != "STOK" {
if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 {
market90Seen[market] = true
}
if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 {
marketTotalSeen[market] = true
}
}
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
if customer != "" && customer != "-" {
if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 {
customer90Seen[customer] = true
}
if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 {
customerTotalSeen[customer] = true
}
}
}
func applyProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) {
if len(market90Seen) > 0 {
row["market_count_90d"] = len(market90Seen)
}
if len(marketTotalSeen) > 0 {
row["market_count_total"] = len(marketTotalSeen)
}
if len(customer90Seen) > 0 {
row["customer_count_90d"] = len(customer90Seen)
}
if len(customerTotalSeen) > 0 {
row["customer_count_total"] = len(customerTotalSeen)
}
if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 {
if intFromMap(row, "market_count_90d") == 0 {
row["market_count_90d"] = 1
}
if intFromMap(row, "customer_count_90d") == 0 {
row["customer_count_90d"] = 1
}
}
if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 {
if intFromMap(row, "market_count_total") == 0 {
row["market_count_total"] = maxInt(1, intFromMap(row, "market_count_90d"))
}
if intFromMap(row, "customer_count_total") == 0 {
row["customer_count_total"] = maxInt(1, intFromMap(row, "customer_count_90d"))
}
}
}
func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string {
groupField = strings.TrimSpace(groupField)
salesQty90 := floatFromMap(row, "sales_qty_90d")
salesUSD90 := floatFromMap(row, "sales_usd_90d")
stockQty := floatFromMap(row, "stock_qty")
marginCost := floatFromMap(row, "gross_margin_cost_90d")
if marginCost == 0 {
marginCost = floatFromMap(row, "gross_margin_90d")
}
switch groupField {
case "market_key":
if salesQty90 <= 0 && stockQty > 0 {
return "Bu piyasada son 90 gunde satis yok"
}
if marginCost < 0 {
return "Bu piyasada ciplak marj negatif"
}
return fmt.Sprintf("Piyasa satisi %.0f adet / %.0f USD", salesQty90, salesUSD90)
case "color_yaka":
if salesQty90 <= 0 && stockQty > 0 {
return "Renk/yaka stokta, son 90 gun satisi yok"
}
if marginCost < 0 {
return "Renk/yaka ciplak marji negatif"
}
return fmt.Sprintf("Renk/yaka satisi %.0f adet", salesQty90)
default:
if recommendation := strings.TrimSpace(stringFromMap(row, "recommendation")); recommendation != "" {
return recommendation
}
return fmt.Sprintf("%d satir", count)
}
}
func isProductPerformanceMarginField(field string) bool {
return strings.HasPrefix(field, "gross_margin")
}
@@ -5300,12 +5774,15 @@ func productPerformanceSalesGroupScore(row map[string]any) float64 {
}
func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
salesUSD := floatFromMap(row, "sales_usd_"+suffix)
salesIndex := floatFromMap(row, "sales_index_"+suffix)
if salesIndex <= 0 && suffix == "total" {
salesIndex = floatFromMap(row, "sales_index_total")
}
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 {
@@ -5313,12 +5790,15 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float
stockTurnover = salesQty / stockQty
}
}
score := minFloat(35, maxFloat(0, salesIndex)*18) +
minFloat(30, maxFloat(0, margin)*60) +
minFloat(15, invoiceCount*1.5) +
minFloat(10, salesQty/10) +
minFloat(10, maxFloat(0, stockTurnover)*5)
return score
return productPerformanceProductScore(
suffix,
salesUSD,
salesIndex,
margin,
stockTurnover,
productPerformancePeriodCount(row, "market_count", suffix),
productPerformancePeriodCount(row, "customer_count", suffix),
)
}
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
@@ -5346,24 +5826,139 @@ func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
salesUSD := floatFromMap(row, "sales_usd")
margin := floatFromMap(row, "gross_margin_cost")
invoiceCount := floatFromMap(row, "invoice_count")
if margin == 0 {
margin = floatFromMap(row, "gross_margin")
}
salesQty := floatFromMap(row, "sales_qty")
productCount := floatFromMap(row, "product_count")
return minFloat(35, salesUSD/1000) +
minFloat(25, maxFloat(0, margin)*55) +
minFloat(20, invoiceCount*2) +
minFloat(10, salesQty/10) +
minFloat(10, productCount)
if groupCount := floatFromMap(row, "product_group_count"); groupCount > 0 {
productCount = groupCount
}
suffix := strings.TrimSpace(stringFromMap(row, "suffix"))
if suffix == "" {
suffix = "90d"
}
return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty)
}
func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64) float64 {
revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTarget(suffix))
score := 0.30*productPerformanceMarginComponentScore(margin) +
0.20*productPerformanceRatioScore(stockTurnover, 1.50) +
0.20*revenueScore +
0.15*productPerformanceRatioScore(marketCount, 8) +
0.15*productPerformanceRatioScore(customerCount, 25)
return productPerformanceRoundScore(score)
}
func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 {
score := 0.35*productPerformanceRatioScore(salesUSD, productPerformanceCustomerRevenueTarget(suffix)) +
0.30*productPerformanceMarginComponentScore(margin) +
0.20*productPerformanceRatioScore(productGroupCount, 8) +
0.15*productPerformanceRatioScore(salesQty, productPerformanceCustomerQtyTarget(suffix))
return productPerformanceRoundScore(score)
}
func productPerformanceRevenueScore(suffix string, salesUSD, salesIndex, absoluteTarget float64) float64 {
if salesIndex > 0 {
return productPerformanceRatioScore(salesIndex, 2)
}
return productPerformanceRatioScore(salesUSD, absoluteTarget)
}
func productPerformanceRelativeIndex(value, average float64) float64 {
if value <= 0 || average <= 0 {
return 0
}
return value / average
}
func productPerformanceMarginComponentScore(margin float64) float64 {
return productPerformanceRatioScore(maxFloat(0, margin), 0.40)
}
func productPerformanceRatioScore(value, target float64) float64 {
if target <= 0 || value <= 0 {
return 0
}
return minFloat(100, maxFloat(0, value)*100/target)
}
func productPerformanceRoundScore(score float64) float64 {
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return math.Round(score*10000) / 10000
}
func productPerformanceProductRevenueTarget(suffix string) float64 {
switch suffix {
case "180d":
return 20000
case "365d":
return 40000
case "total":
return 120000
default:
return 10000
}
}
func productPerformanceCustomerRevenueTarget(suffix string) float64 {
switch suffix {
case "180d":
return 50000
case "365d":
return 100000
case "total":
return 300000
default:
return 25000
}
}
func productPerformanceCustomerQtyTarget(suffix string) float64 {
switch suffix {
case "180d":
return 1000
case "365d":
return 2000
case "total":
return 6000
default:
return 500
}
}
func productPerformancePeriodCount(row map[string]any, prefix, suffix string) float64 {
if value, ok := row[prefix+"_"+suffix]; ok {
if n := floatFromAny(value); n > 0 {
return n
}
}
if suffix == "total" {
if value, ok := row[prefix+"_total"]; ok {
return floatFromAny(value)
}
}
if value, ok := row[prefix+"_90d"]; ok {
return floatFromAny(value)
}
return floatFromAny(row[prefix])
}
func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
return map[string]any{
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
"invoice_count": floatFromMap(row, "invoice_count_"+suffix),
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
"product_count": floatFromMap(row, "product_count"),
"suffix": suffix,
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
"product_group_count": productPerformancePeriodCount(row, "product_group_count", suffix),
"product_count": floatFromMap(row, "product_count"),
}
}
@@ -5608,9 +6203,7 @@ func cleanProductPerformanceFirstGroup(value string) string {
if value == "-" {
return ""
}
normalized := strings.ToUpper(value)
normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized)
switch normalized {
switch normalizeProductPerformanceTurkishText(value) {
case "YETISKIN", "YETISKIN/GARSON", "GARSON":
return ""
default:
@@ -5618,6 +6211,24 @@ func cleanProductPerformanceFirstGroup(value string) string {
}
}
func normalizeProductPerformanceTurkishText(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
return strings.NewReplacer(
"\u0130", "I",
"\u015E", "S",
"\u011E", "G",
"\u00DC", "U",
"\u00D6", "O",
"\u00C7", "C",
"\u0131", "I",
"\u015F", "S",
"\u011F", "G",
"\u00FC", "U",
"\u00F6", "O",
"\u00E7", "C",
).Replace(value)
}
func cleanProductPerformanceOptionalAttr(value string) string {
value = strings.TrimSpace(value)
if value == "-" {
@@ -5659,6 +6270,13 @@ func minInt(a, b int) int {
return b
}
func maxInt(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
@@ -5679,7 +6297,7 @@ FROM mk_product_performance_sales_daily
WHERE product_code=$1
AND color_code=$2
AND yaka_kodu=$3
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
ORDER BY sales_usd DESC, sales_date DESC
LIMIT $4
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
@@ -5936,15 +6554,24 @@ func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (fl
return costPriceUSD, basePriceUSD
}
const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')"
const productPerformanceTurkishTranslateSQL = "U&'\\0130\\015E\\011E\\00DC\\00D6\\00C7\\0131\\015F\\011F\\00FC\\00F6\\00E7', 'ISGUOCisguoc'"
const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')"
func productPerformanceNormalizedTextSQL(expr string) string {
return "upper(translate(btrim(COALESCE(" + expr + ",'')), " + productPerformanceTurkishTranslateSQL + "))"
}
func productPerformanceCleanFirstGroupSQL(expr string) string {
return "CASE WHEN btrim(COALESCE(" + expr + ",'')) = '-' THEN '' WHEN " + productPerformanceNormalizedTextSQL(expr) + " IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(" + expr + ",'') END"
}
func productPerformanceAllowedFirstGroupSQL(expr string) string {
return "upper(btrim(COALESCE(" + expr + ",''))) NOT IN " + productPerformanceExcludedFirstGroupsSQL
return productPerformanceNormalizedTextSQL(expr) + " NOT IN " + productPerformanceExcludedFirstGroupsSQL
}
func isExcludedProductPerformanceFirstGroup(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "MALZEMELI FASON", "MALZEMESIZ FASON", "DIGER":
switch normalizeProductPerformanceTurkishText(value) {
case "MALZEMELI FASON", "MALZEMESIZ FASON", "MAZLEMELI FASON", "MAZEMESIZ FASON", "DIGER":
return true
default:
return false
@@ -6013,7 +6640,7 @@ SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest)
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
`, pq.Array(variantKeys))
if err != nil {
@@ -6069,68 +6696,68 @@ GROUP BY product_code, color_code, yaka_kodu
func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) {
switch {
case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Açık sipariş stoktan büyük. Karlı talep var; üretim/satın alma önceliği ver."
return "STOKSUZ_TALEP", "Acik siparis stoktan buyuk. Karli talep var; uretim/satin alma onceligi ver."
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Açık sipariş çıplak maliyete göre zarar yazıyor. Fiyat/maliyet kontrol edilmeli."
return "FIYAT_BASKISI", "Acik siparis ciplak maliyete gore zarar yaziyor. Fiyat/maliyet kontrol edilmeli."
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder >= 0:
return "YILDIZ_URUN", "Açık sipariş karlı ve stok karşılıyor. Teslimat korunmalı."
return "YILDIZ_URUN", "Acik siparis karli ve stok karsiliyor. Teslimat korunmali."
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0:
return "FIYAT_FIRSATI", "Karlı talep var ama stok yetersiz. Üretim planına alınmalı."
return "FIYAT_FIRSATI", "Karli talep var ama stok yetersiz. Uretim planina alinmali."
case row.OverdueQty > 0:
return "TAKIP", "Termin gecikmesi olan açık sipariş var. Operasyon takibi gerekli."
return "TAKIP", "Termin gecikmesi olan acik siparis var. Operasyon takibi gerekli."
default:
return "TAKIP", "Sipariş, stok ve fiyat düzenli izlenmeli."
return "TAKIP", "Siparis, stok ve fiyat duzenli izlenmeli."
}
}
func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Çıplak maliyete göre zarar yazan açık sipariş var. Fiyat/maliyet acil kontrol edilmeli."
return "FIYAT_BASKISI", "Ciplak maliyete gore zarar yazan acik siparis var. Fiyat/maliyet acil kontrol edilmeli."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Taban maliyete göre brüt zarar var. Satış fiyatı veya iskonto kontrol edilmeli."
return "FIYAT_BASKISI", "Taban maliyete gore brut zarar var. Satis fiyati veya iskonto kontrol edilmeli."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Karlı açık talep var ama stok yetersiz. Üretim/satın alma önceliği ver."
return "STOKSUZ_TALEP", "Karli acik talep var ama stok yetersiz. Uretim/satin alma onceligi ver."
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
return "YILDIZ_URUN", "Piyasa/müşteri açık siparişleri karlı. Teslimat ve stok korunmalı."
return "YILDIZ_URUN", "Piyasa/musteri acik siparisleri karli. Teslimat ve stok korunmali."
case row.OverdueQty > 0:
return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli."
return "TAKIP", "Geciken acik siparis var. Operasyon takibi gerekli."
default:
return "TAKIP", "Sipariş karlılığı ve stok yeterliliği izlenmeli."
return "TAKIP", "Siparis karliligi ve stok yeterliligi izlenmeli."
}
}
func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi çıplak maliyete göre zarar yazıyor."
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi ciplak maliyete gore zarar yaziyor."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi taban maliyete göre brüt zarar yazıyor."
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi taban maliyete gore brut zarar yaziyor."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Müşteride karlı talep var ama stok yetersiz."
return "STOKSUZ_TALEP", "Musteride karli talep var ama stok yetersiz."
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
return "YILDIZ_URUN", "Müşteri bazında karlı açık talep var."
return "YILDIZ_URUN", "Musteri bazinda karli acik talep var."
case row.OverdueQty > 0:
return "TAKIP", "Bu müşteri/ürün kırılımında geciken açık sipariş var."
return "TAKIP", "Bu musteri/urun kiriliminda geciken acik siparis var."
default:
return "TAKIP", "Müşteri talebi, fiyat ve stok birlikte izlenmeli."
return "TAKIP", "Musteri talebi, fiyat ve stok birlikte izlenmeli."
}
}
func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Sipariş satırı çıplak maliyete göre zarar yazıyor."
return "FIYAT_BASKISI", "Siparis satiri ciplak maliyete gore zarar yaziyor."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Sipariş satırı taban maliyete göre brüt zarar yazıyor."
return "FIYAT_BASKISI", "Siparis satiri taban maliyete gore brut zarar yaziyor."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Karlı sipariş var ama mevcut stok siparişi karşılamıyor."
return "STOKSUZ_TALEP", "Karli siparis var ama mevcut stok siparisi karsilamiyor."
case row.IsOverdue:
return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli."
return "TAKIP", "Termin gecmis; teslimat aksiyonu gerekli."
case row.ExpectedMarginCost >= 0.25:
return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı."
return "YILDIZ_URUN", "Siparis karli; teslimat ve stok korunmali."
default:
return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli."
return "TAKIP", "Siparis fiyati, stok ve termin izlenmeli."
}
}
+9 -9
View File
@@ -455,7 +455,7 @@ SalesAgg AS (
FROM mk_product_performance_sales_daily
WHERE sales_date >= DATE '2022-01-01'
AND sales_date <= $1::date
AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
GROUP BY product_code, color_code, yaka_kodu, market_key
),
Scope AS (
@@ -497,7 +497,7 @@ Scope AS (
FROM LatestStock ls
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code
WHERE COALESCE(ls.stock_qty, 0) <> 0
AND upper(btrim(COALESCE(pd.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
AND upper(translate(btrim(COALESCE(pd.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
AND NOT EXISTS (
SELECT 1
FROM SalesAgg sa
@@ -559,7 +559,7 @@ Base AS (
LEFT JOIN Stock365Start s365 ON s365.product_code=s.product_code AND s365.color_code=s.color_code AND s365.yaka_kodu=s.yaka_kodu
LEFT JOIN StockTotalStart stotal ON stotal.product_code=s.product_code AND stotal.color_code=s.color_code AND stotal.yaka_kodu=s.yaka_kodu
LEFT JOIN PriceDim pd ON pd.product_code=s.product_code
WHERE upper(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, ''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')
WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, '')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
),
Spread AS (
SELECT
@@ -638,12 +638,12 @@ SELECT
ELSE 'TAKIP'
END AS performance_bucket,
CASE
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa üstü satış ve iyi marj: stok ve fiyat gücü takip edilmeli.'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: üretim/satın alma planına alınmalı.'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.'
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.'
ELSE 'Düzenli takip.'
WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.'
WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.'
WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yuksek, satis piyasa alti: kampanya veya fiyat kontrolu gerekli.'
WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.'
WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satis guclu, fiyat piyasa alti: taban fiyat artisi degerlendirilebilir.'
ELSE 'Duzenli takip.'
END AS recommendation,
last_sale_date,
COALESCE(last_ref_number, ''),