package queries import ( "bssapp-backend/db" "bssapp-backend/models" "context" "database/sql" "encoding/json" "fmt" "log" "sort" "strings" "time" "github.com/lib/pq" ) type ProductPerformanceFilters struct { Search string ProductCode string MarketKey string Kategori string Seri string Bucket string SortBy string Descending bool Limit int Page int } type ProductPerformanceRefreshRequest struct { Mode string Stage string ResumeAfter int SkipDelete bool ProductPrefix string StartDate time.Time EndDate time.Time } type ProductPerformanceRefreshResult struct { Mode string `json:"mode"` Stage string `json:"stage"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` SalesRows int `json:"sales_rows"` StockRows int `json:"stock_rows"` KpiRows int `json:"kpi_rows"` DurationMS int64 `json:"duration_ms"` } func productPerformanceStockQuery() string { return ` ;WITH ActiveWarehouses AS ( SELECT WarehouseCode FROM (VALUES ('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'), ('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'), ('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36') ) W(WarehouseCode) ), Raw AS ( SELECT ProductCode = LTRIM(RTRIM(S.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))), MovementDate = CAST(S.DocumentDate AS date), ProcessCode = CASE WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode)) ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, ''))) END, InQty = SUM(S.In_Qty1), OutQty = SUM(S.Out_Qty1), NetQty = SUM(S.In_Qty1 - S.Out_Qty1) FROM trStock S WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) WHERE S.ItemTypeCode = 1 AND ( LEN(LTRIM(RTRIM(S.ItemCode))) = 13 AND ( S.ItemCode LIKE 'S%' OR S.ItemCode LIKE 'O%' OR S.ItemCode LIKE 'N%' OR S.ItemCode LIKE 'X%' OR S.ItemCode LIKE 'I%' OR S.ItemCode LIKE 'A%' ) ) AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%') AND S.DocumentDate < DATEADD(DAY, 1, @p2) GROUP BY S.ItemCode, S.ColorCode, S.ItemDim2Code, CAST(S.DocumentDate AS date), CASE WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode)) ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, ''))) END ), Opening AS ( SELECT StockDate = @p1, ProductCode, ColorCode, YakaKodu, NetQty = SUM(NetQty), InQty = CAST(0 AS decimal(18,4)), OutQty = CAST(0 AS decimal(18,4)), KpiInQty = CAST(0 AS decimal(18,4)), KpiOutQty = CAST(0 AS decimal(18,4)), SalesMovementQty = CAST(0 AS decimal(18,4)), ProductionInQty = CAST(0 AS decimal(18,4)), PurchaseInQty = CAST(0 AS decimal(18,4)), ConsumptionOutQty = CAST(0 AS decimal(18,4)), CountDiffQty = CAST(0 AS decimal(18,4)) FROM Raw WHERE MovementDate < @p1 GROUP BY ProductCode, ColorCode, YakaKodu ), Daily AS ( SELECT StockDate = MovementDate, ProductCode, ColorCode, YakaKodu, NetQty = SUM(NetQty), InQty = SUM(InQty), OutQty = SUM(OutQty), KpiInQty = SUM(CASE WHEN ProcessCode IN ('OP','BP','CI') THEN InQty ELSE 0 END), KpiOutQty = SUM(CASE WHEN ProcessCode IN ('R','WS','OC','CO') THEN OutQty ELSE 0 END), SalesMovementQty = SUM(CASE WHEN ProcessCode IN ('R','WS') THEN OutQty ELSE 0 END), ProductionInQty = SUM(CASE WHEN ProcessCode = 'OP' THEN InQty ELSE 0 END), PurchaseInQty = SUM(CASE WHEN ProcessCode = 'BP' THEN InQty ELSE 0 END), ConsumptionOutQty = SUM(CASE WHEN ProcessCode = 'OC' THEN OutQty ELSE 0 END), CountDiffQty = SUM(CASE WHEN ProcessCode IN ('CO','CI') THEN NetQty ELSE 0 END) FROM Raw WHERE MovementDate BETWEEN @p1 AND @p2 GROUP BY MovementDate, ProductCode, ColorCode, YakaKodu ), Series AS ( SELECT * FROM Opening UNION ALL SELECT * FROM Daily ), Collapsed AS ( SELECT StockDate, ProductCode, ColorCode, YakaKodu, NetQty = SUM(NetQty), InQty = SUM(InQty), OutQty = SUM(OutQty), KpiInQty = SUM(KpiInQty), KpiOutQty = SUM(KpiOutQty), SalesMovementQty = SUM(SalesMovementQty), ProductionInQty = SUM(ProductionInQty), PurchaseInQty = SUM(PurchaseInQty), ConsumptionOutQty = SUM(ConsumptionOutQty), CountDiffQty = SUM(CountDiffQty) FROM Series GROUP BY StockDate, ProductCode, ColorCode, YakaKodu ), Running AS ( SELECT StockDate, ProductCode, ColorCode, YakaKodu, StockQty = SUM(NetQty) OVER ( PARTITION BY ProductCode, ColorCode, YakaKodu ORDER BY StockDate ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ), InQty, OutQty, KpiInQty, KpiOutQty, SalesMovementQty, ProductionInQty, PurchaseInQty, ConsumptionOutQty, CountDiffQty FROM Collapsed ), CurrentStock AS ( SELECT ProductCode = LTRIM(RTRIM(S.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))), InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1) FROM trStock S WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) WHERE S.ItemTypeCode = 1 AND ( LEN(LTRIM(RTRIM(S.ItemCode))) = 13 AND ( S.ItemCode LIKE 'S%' OR S.ItemCode LIKE 'O%' OR S.ItemCode LIKE 'N%' OR S.ItemCode LIKE 'X%' OR S.ItemCode LIKE 'I%' OR S.ItemCode LIKE 'A%' ) ) AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%') AND S.DocumentDate < DATEADD(DAY, 1, @p2) GROUP BY S.ItemCode, S.ColorCode, S.ItemDim2Code ), CurrentPick AS ( SELECT ProductCode = LTRIM(RTRIM(P.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(P.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))), PickingQty1 = SUM(P.Qty1) FROM PickingStates P WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)) WHERE P.ItemTypeCode = 1 AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13 AND (@p3 = '' OR P.ItemCode LIKE @p3 + '%') GROUP BY P.ItemCode, P.ColorCode, P.ItemDim2Code ), CurrentReserve AS ( SELECT ProductCode = LTRIM(RTRIM(R.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(R.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))), ReserveQty1 = SUM(R.Qty1) FROM ReserveStates R WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)) WHERE R.ItemTypeCode = 1 AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13 AND (@p3 = '' OR R.ItemCode LIKE @p3 + '%') GROUP BY R.ItemCode, R.ColorCode, R.ItemDim2Code ), CurrentDisp AS ( SELECT ProductCode = LTRIM(RTRIM(D.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(D.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))), DispOrderQty1 = SUM(D.Qty1) FROM DispOrderStates D WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)) WHERE D.ItemTypeCode = 1 AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13 AND (@p3 = '' OR D.ItemCode LIKE @p3 + '%') GROUP BY D.ItemCode, D.ColorCode, D.ItemDim2Code ), CurrentAvailable AS ( SELECT StockDate = @p2, S.ProductCode, S.ColorCode, S.YakaKodu, StockQty = CAST(ROUND( ISNULL(S.InventoryQty1, 0) - ISNULL(PK.PickingQty1, 0) - ISNULL(RS.ReserveQty1, 0) - ISNULL(DP.DispOrderQty1, 0), 2 ) AS decimal(18,4)), InQty = CAST(0 AS decimal(18,4)), OutQty = CAST(0 AS decimal(18,4)), KpiInQty = CAST(0 AS decimal(18,4)), KpiOutQty = CAST(0 AS decimal(18,4)), SalesMovementQty = CAST(0 AS decimal(18,4)), ProductionInQty = CAST(0 AS decimal(18,4)), PurchaseInQty = CAST(0 AS decimal(18,4)), ConsumptionOutQty = CAST(0 AS decimal(18,4)), CountDiffQty = CAST(0 AS decimal(18,4)) FROM CurrentStock S LEFT JOIN CurrentPick PK ON PK.ProductCode = S.ProductCode AND PK.ColorCode = S.ColorCode AND PK.YakaKodu = S.YakaKodu LEFT JOIN CurrentReserve RS ON RS.ProductCode = S.ProductCode AND RS.ColorCode = S.ColorCode AND RS.YakaKodu = S.YakaKodu LEFT JOIN CurrentDisp DP ON DP.ProductCode = S.ProductCode AND DP.ColorCode = S.ColorCode AND DP.YakaKodu = S.YakaKodu ) SELECT StockDate, ProductCode, ColorCode, YakaKodu, StockQty, InQty, OutQty, KpiInQty, KpiOutQty, SalesMovementQty, ProductionInQty, PurchaseInQty, ConsumptionOutQty, CountDiffQty FROM ( SELECT * FROM Running WHERE StockDate BETWEEN @p1 AND DATEADD(DAY, -1, @p2) UNION ALL SELECT * FROM CurrentAvailable ) X ORDER BY StockDate, ProductCode, ColorCode, YakaKodu ` } func EnsureProductPerformanceTables(pg *sql.DB) error { stmts := []string{ ` CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily ( sales_date DATE NOT NULL, product_code TEXT NOT NULL, color_code TEXT NOT NULL DEFAULT '', yaka_kodu TEXT NOT NULL DEFAULT '', item_description TEXT NOT NULL DEFAULT '', kategori TEXT NOT NULL DEFAULT '', seri TEXT NOT NULL DEFAULT '', yas_grubu TEXT NOT NULL DEFAULT '', askili_yan TEXT NOT NULL DEFAULT '', urun_ilk_grubu TEXT NOT NULL DEFAULT '', urun_ana_grubu TEXT NOT NULL DEFAULT '', urun_alt_grubu TEXT NOT NULL DEFAULT '', market_key TEXT NOT NULL DEFAULT '', channel_code TEXT NOT NULL DEFAULT '', customer_country TEXT NOT NULL DEFAULT '', customer_segment TEXT NOT NULL DEFAULT '', customer_code TEXT NOT NULL DEFAULT '', customer_name TEXT NOT NULL DEFAULT '', sales_qty NUMERIC(18,4) NOT NULL DEFAULT 0, sales_tl NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd NUMERIC(18,4) NOT NULL DEFAULT 0, avg_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, invoice_line_count INTEGER NOT NULL DEFAULT 0, invoice_count INTEGER NOT NULL DEFAULT 0, customer_count INTEGER NOT NULL DEFAULT 0, last_ref_number TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code) )`, `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_code TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, ` 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')`, ` UPDATE mk_product_performance_sales_daily SET askili_yan = '', updated_at = now() WHERE btrim(askili_yan) = '-'`, ` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'pk_mk_product_performance_sales_daily' AND conrelid = 'mk_product_performance_sales_daily'::regclass ) THEN ALTER TABLE mk_product_performance_sales_daily DROP CONSTRAINT pk_mk_product_performance_sales_daily; END IF; ALTER TABLE mk_product_performance_sales_daily ADD CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code); END $$`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_product_date ON mk_product_performance_sales_daily (product_code, sales_date DESC)`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_market ON mk_product_performance_sales_daily (market_key, sales_date DESC)`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_customer ON mk_product_performance_sales_daily (customer_code, sales_date DESC)`, ` CREATE TABLE IF NOT EXISTS mk_product_performance_stock_daily ( stock_date DATE NOT NULL, product_code TEXT NOT NULL, color_code TEXT NOT NULL DEFAULT '', yaka_kodu TEXT NOT NULL DEFAULT '', stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0, in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, kpi_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, kpi_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, sales_movement_qty NUMERIC(18,4) NOT NULL DEFAULT 0, production_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, purchase_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0, consumption_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0, count_diff_qty NUMERIC(18,4) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT pk_mk_product_performance_stock_daily PRIMARY KEY (stock_date, product_code, color_code, yaka_kodu) )`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_stock_product_date ON mk_product_performance_stock_daily (product_code, stock_date DESC)`, ` CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim ( product_code TEXT PRIMARY KEY, item_description TEXT NOT NULL DEFAULT '', kategori TEXT NOT NULL DEFAULT '', askili_yan TEXT NOT NULL DEFAULT '', urun_ilk_grubu TEXT NOT NULL DEFAULT '', urun_ana_grubu TEXT NOT NULL DEFAULT '', urun_alt_grubu TEXT NOT NULL DEFAULT '', cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0, last_pricing_date DATE, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS item_description TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS kategori TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS askili_yan TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, ` CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( kpi_date DATE NOT NULL, product_code TEXT NOT NULL, color_code TEXT NOT NULL DEFAULT '', yaka_kodu TEXT NOT NULL DEFAULT '', item_description TEXT NOT NULL DEFAULT '', kategori TEXT NOT NULL DEFAULT '', seri TEXT NOT NULL DEFAULT '', yas_grubu TEXT NOT NULL DEFAULT '', askili_yan TEXT NOT NULL DEFAULT '', urun_ilk_grubu TEXT NOT NULL DEFAULT '', urun_ana_grubu TEXT NOT NULL DEFAULT '', urun_alt_grubu TEXT NOT NULL DEFAULT '', market_key TEXT NOT NULL DEFAULT '', stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0, sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0, avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0, stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0, avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0, cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0, base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0, gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0, gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0, gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0, gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0, unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0, market_count_90d INTEGER NOT NULL DEFAULT 0, customer_count_90d INTEGER NOT NULL DEFAULT 0, sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0, performance_score NUMERIC(18,6) NOT NULL DEFAULT 0, performance_bucket TEXT NOT NULL DEFAULT '', recommendation TEXT NOT NULL DEFAULT '', last_sale_date DATE, last_ref_number TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT pk_mk_product_performance_kpi_daily PRIMARY KEY (kpi_date, product_code, color_code, yaka_kodu, market_key) )`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_bucket ON mk_product_performance_kpi_daily (kpi_date DESC, performance_bucket)`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_market ON mk_product_performance_kpi_daily (kpi_date DESC, market_key, sales_index_90d DESC)`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, ` 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')`, ` UPDATE mk_product_performance_kpi_daily SET askili_yan = '', updated_at = now() WHERE btrim(askili_yan) = '-'`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range stmts { if _, err := pg.Exec(stmt); err != nil { return err } } return nil } func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) { started := time.Now() stage := productPerformanceRefreshStage(req.Stage) productPrefix := productPerformanceProductPrefix(req.ProductPrefix) log.Printf("[ProductPerformanceRefresh] start mode=%s stage=%s prefix=%s start=%s end=%s", req.Mode, stage, productPrefix, req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02")) if pg == nil { return ProductPerformanceRefreshResult{}, fmt.Errorf("postgres db nil") } if db.MssqlDB == nil { return ProductPerformanceRefreshResult{}, fmt.Errorf("mssql db nil") } log.Printf("[ProductPerformanceRefresh] ensure tables start") if err := EnsureProductPerformanceTables(pg); err != nil { return ProductPerformanceRefreshResult{}, err } log.Printf("[ProductPerformanceRefresh] ensure tables done") mode := strings.ToLower(strings.TrimSpace(req.Mode)) if mode == "" { mode = "delta" } if req.EndDate.IsZero() { req.EndDate = time.Now() } if req.StartDate.IsZero() { if mode == "full" { req.StartDate = time.Date(2022, 1, 1, 0, 0, 0, 0, req.EndDate.Location()) } else { req.StartDate = req.EndDate.AddDate(0, 0, -45) } } req.StartDate = dateOnly(req.StartDate) req.EndDate = dateOnly(req.EndDate) shouldRun := func(name string) bool { if stage == "all" { return true } order := map[string]int{ "sales": 1, "stock": 2, "price": 3, "kpi": 4, } return order[name] >= order[stage] } runStage := func(stage string, fn func(*sql.Tx) error) error { log.Printf("[ProductPerformanceRefresh] %s tx begin", stage) tx, err := pg.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err := fn(tx); err != nil { return err } log.Printf("[ProductPerformanceRefresh] %s tx commit start", stage) if err := tx.Commit(); err != nil { return err } log.Printf("[ProductPerformanceRefresh] %s tx commit done elapsed=%s", stage, time.Since(started).Round(time.Second)) return nil } var salesRows int if shouldRun("sales") { if err := runStage("sales", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] delete sales cache start") deleteSQL := `DELETE FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN $1 AND $2` deleteArgs := []any{req.StartDate, req.EndDate} if productPrefix != "" { deleteSQL += ` AND product_code ILIKE $3 || '%'` deleteArgs = append(deleteArgs, productPrefix) } if _, err := tx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil { return err } log.Printf("[ProductPerformanceRefresh] delete sales cache done") log.Printf("[ProductPerformanceRefresh] sales refresh start") rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate, productPrefix) if err != nil { return err } salesRows = rows log.Printf("[ProductPerformanceRefresh] sales refresh done rows=%d elapsed=%s", salesRows, time.Since(started).Round(time.Second)) return nil }); err != nil { return ProductPerformanceRefreshResult{}, err } } else { log.Printf("[ProductPerformanceRefresh] sales skipped stage=%s", stage) } var stockRows int if shouldRun("stock") { rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter, productPrefix) if err != nil { return ProductPerformanceRefreshResult{}, err } stockRows = rows } else { log.Printf("[ProductPerformanceRefresh] stock skipped stage=%s", stage) } if shouldRun("price") { if err := runStage("price", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] price refresh start") if err := refreshProductPerformancePrices(ctx, tx, productPrefix); err != nil { return err } log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second)) return nil }); err != nil { return ProductPerformanceRefreshResult{}, err } } else { log.Printf("[ProductPerformanceRefresh] price skipped stage=%s", stage) } var kpiRows int if shouldRun("kpi") { if err := runStage("kpi", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] kpi rebuild start") rows, err := RebuildProductPerformanceKPI(ctx, tx, req.EndDate) if err != nil { return err } kpiRows = rows log.Printf("[ProductPerformanceRefresh] kpi rebuild done rows=%d elapsed=%s", kpiRows, time.Since(started).Round(time.Second)) return nil }); err != nil { return ProductPerformanceRefreshResult{}, err } } else { log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage) } log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second)) return ProductPerformanceRefreshResult{ Mode: mode, Stage: stage, StartDate: req.StartDate.Format("2006-01-02"), EndDate: req.EndDate.Format("2006-01-02"), SalesRows: salesRows, StockRows: stockRows, KpiRows: kpiRows, DurationMS: time.Since(started).Milliseconds(), }, nil } func productPerformanceRefreshStage(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "", "all", "full": return "all" case "sales", "stock", "price", "kpi": return strings.ToLower(strings.TrimSpace(raw)) default: return "all" } } func productPerformanceProductPrefix(raw string) string { prefix := strings.ToUpper(strings.TrimSpace(raw)) switch prefix { case "", "S", "O", "N", "X", "I", "A": return prefix default: return "" } } func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) { log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate, productPrefix) if err != nil { return 0, err } defer rows.Close() log.Printf("[ProductPerformanceRefresh] sales mssql query returned, postgres insert start") count := 0 for rows.Next() { var r productPerformanceSalesDaily if err := rows.Scan( &r.SalesDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.ChannelCode, &r.CustomerCountry, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesTL, &r.SalesUSD, &r.AvgPriceUSD, &r.InvoiceLineCount, &r.InvoiceCount, &r.CustomerCount, &r.LastRefNumber, ); err != nil { return count, err } r.AskiliYan = cleanProductPerformanceOptionalAttr(r.AskiliYan) r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) if _, err := tx.ExecContext(ctx, ` INSERT INTO mk_product_performance_sales_daily ( sales_date, product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, channel_code, customer_country, customer_segment, customer_code, customer_name, sales_qty, sales_tl, sales_usd, avg_price_usd, invoice_line_count, invoice_count, customer_count, last_ref_number, updated_at ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,now() ) ON CONFLICT (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code) DO UPDATE SET item_description=EXCLUDED.item_description, kategori=EXCLUDED.kategori, seri=EXCLUDED.seri, yas_grubu=EXCLUDED.yas_grubu, askili_yan=EXCLUDED.askili_yan, urun_ilk_grubu=EXCLUDED.urun_ilk_grubu, urun_ana_grubu=EXCLUDED.urun_ana_grubu, urun_alt_grubu=EXCLUDED.urun_alt_grubu, channel_code=EXCLUDED.channel_code, customer_name=EXCLUDED.customer_name, sales_qty=EXCLUDED.sales_qty, sales_tl=EXCLUDED.sales_tl, sales_usd=EXCLUDED.sales_usd, avg_price_usd=EXCLUDED.avg_price_usd, invoice_line_count=EXCLUDED.invoice_line_count, invoice_count=EXCLUDED.invoice_count, customer_count=EXCLUDED.customer_count, last_ref_number=EXCLUDED.last_ref_number, updated_at=now() `, r.SalesDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.ItemDescription, r.Kategori, r.Seri, r.YasGrubu, r.AskiliYan, r.UrunIlkGrubu, r.UrunAnaGrubu, r.UrunAltGrubu, r.MarketKey, r.ChannelCode, r.CustomerCountry, r.CustomerSegment, r.CustomerCode, r.CustomerName, r.SalesQty, r.SalesTL, r.SalesUSD, r.AvgPriceUSD, r.InvoiceLineCount, r.InvoiceCount, r.CustomerCount, r.LastRefNumber); err != nil { return count, err } count++ if count%5000 == 0 { log.Printf("[ProductPerformanceRefresh] sales inserted rows=%d", count) } } return count, rows.Err() } func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) { log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockQuery(), startDate, endDate, productPrefix) if err != nil { return 0, err } defer rows.Close() log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres insert start") count := 0 for rows.Next() { var r productPerformanceStockDaily if err := rows.Scan( &r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty, &r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty, &r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty, ); err != nil { return count, err } if err := insertProductPerformanceStock(ctx, tx, r); err != nil { return count, err } count++ if count%5000 == 0 { log.Printf("[ProductPerformanceRefresh] stock inserted rows=%d", count) } } return count, rows.Err() } func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, startDate, endDate, started time.Time, skipDelete bool, resumeAfter int, productPrefix string) (int, error) { if skipDelete { log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter) } else { log.Printf("[ProductPerformanceRefresh] stock delete tx begin") deleteTx, err := pg.BeginTx(ctx, nil) if err != nil { return 0, err } defer deleteTx.Rollback() log.Printf("[ProductPerformanceRefresh] delete stock cache start") deleteSQL := `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2` deleteArgs := []any{startDate, endDate} if productPrefix != "" { deleteSQL += ` AND product_code ILIKE $3 || '%'` deleteArgs = append(deleteArgs, productPrefix) } if _, err := deleteTx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil { return 0, err } log.Printf("[ProductPerformanceRefresh] delete stock cache done") log.Printf("[ProductPerformanceRefresh] stock delete tx commit start") if err := deleteTx.Commit(); err != nil { return 0, err } log.Printf("[ProductPerformanceRefresh] stock delete tx commit done elapsed=%s", time.Since(started).Round(time.Second)) } log.Printf("[ProductPerformanceRefresh] stock refresh start") log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockQuery(), startDate, endDate, productPrefix) if err != nil { return 0, err } defer rows.Close() log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres chunk upsert start") const chunkSize = 5000 count := 0 seen := 0 chunkRows := 0 tx, err := pg.BeginTx(ctx, nil) if err != nil { return 0, err } defer tx.Rollback() for rows.Next() { var r productPerformanceStockDaily if err := rows.Scan( &r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty, &r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty, &r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty, ); err != nil { return count, err } seen++ if resumeAfter > 0 && seen <= resumeAfter { if seen%50000 == 0 || seen == resumeAfter { log.Printf("[ProductPerformanceRefresh] stock resume skipped rows=%d", seen) } continue } if err := insertProductPerformanceStock(ctx, tx, r); err != nil { return count, err } count++ chunkRows++ if chunkRows >= chunkSize { log.Printf("[ProductPerformanceRefresh] stock chunk commit start upserted_rows=%d scanned_rows=%d", count, seen) if err := tx.Commit(); err != nil { return count, err } log.Printf("[ProductPerformanceRefresh] stock chunk commit done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) tx, err = pg.BeginTx(ctx, nil) if err != nil { return count, err } chunkRows = 0 } } if err := rows.Err(); err != nil { return count, err } if chunkRows > 0 { log.Printf("[ProductPerformanceRefresh] stock final chunk commit start upserted_rows=%d scanned_rows=%d", count, seen) if err := tx.Commit(); err != nil { return count, err } log.Printf("[ProductPerformanceRefresh] stock final chunk commit done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) } else { _ = tx.Rollback() } log.Printf("[ProductPerformanceRefresh] stock refresh done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second)) return count, nil } type productPerformanceStockExec interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } func prepareProductPerformanceStockCopy(ctx context.Context, tx *sql.Tx) (*sql.Stmt, error) { return tx.PrepareContext(ctx, pq.CopyIn( "mk_product_performance_stock_daily", "stock_date", "product_code", "color_code", "yaka_kodu", "stock_qty", "in_qty", "out_qty", "kpi_in_qty", "kpi_out_qty", "sales_movement_qty", "production_in_qty", "purchase_in_qty", "consumption_out_qty", "count_diff_qty", )) } func insertProductPerformanceStock(ctx context.Context, exec productPerformanceStockExec, r productPerformanceStockDaily) error { _, err := exec.ExecContext(ctx, ` INSERT INTO mk_product_performance_stock_daily ( stock_date, product_code, color_code, yaka_kodu, stock_qty, in_qty, out_qty, kpi_in_qty, kpi_out_qty, sales_movement_qty, production_in_qty, purchase_in_qty, consumption_out_qty, count_diff_qty, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now()) ON CONFLICT (stock_date, product_code, color_code, yaka_kodu) DO UPDATE SET stock_qty=EXCLUDED.stock_qty, in_qty=EXCLUDED.in_qty, out_qty=EXCLUDED.out_qty, kpi_in_qty=EXCLUDED.kpi_in_qty, kpi_out_qty=EXCLUDED.kpi_out_qty, sales_movement_qty=EXCLUDED.sales_movement_qty, production_in_qty=EXCLUDED.production_in_qty, purchase_in_qty=EXCLUDED.purchase_in_qty, consumption_out_qty=EXCLUDED.consumption_out_qty, count_diff_qty=EXCLUDED.count_diff_qty, updated_at=now() `, r.StockDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.StockQty, r.InQty, r.OutQty, r.KpiInQty, r.KpiOutQty, r.SalesMovementQty, r.ProductionInQty, r.PurchaseInQty, r.ConsumptionOutQty, r.CountDiffQty) return err } func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx, productPrefix string) error { log.Printf("[ProductPerformanceRefresh] price mssql query start prefix=%s", productPrefix) rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL(), productPrefix) if err != nil { return err } defer rows.Close() log.Printf("[ProductPerformanceRefresh] price mssql query returned, postgres upsert start") count := 0 for rows.Next() { var code string var itemDescription, kategori, askiliYan, urunIlkGrubu, urunAnaGrubu, urunAltGrubu string var cost, usd, tryPrice float64 var lastPricing sql.NullTime if err := rows.Scan(&code, &itemDescription, &kategori, &askiliYan, &urunIlkGrubu, &urunAnaGrubu, &urunAltGrubu, &cost, &usd, &tryPrice, &lastPricing); err != nil { return err } askiliYan = cleanProductPerformanceOptionalAttr(askiliYan) urunIlkGrubu = cleanProductPerformanceFirstGroup(urunIlkGrubu) var lp any if lastPricing.Valid { lp = lastPricing.Time } if _, err := tx.ExecContext(ctx, ` INSERT INTO mk_product_performance_price_dim ( product_code, item_description, kategori, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, cost_price_usd, base_price_usd, base_price_try, last_pricing_date, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now()) ON CONFLICT (product_code) DO UPDATE SET item_description=EXCLUDED.item_description, kategori=EXCLUDED.kategori, askili_yan=EXCLUDED.askili_yan, urun_ilk_grubu=EXCLUDED.urun_ilk_grubu, urun_ana_grubu=EXCLUDED.urun_ana_grubu, urun_alt_grubu=EXCLUDED.urun_alt_grubu, cost_price_usd=EXCLUDED.cost_price_usd, base_price_usd=EXCLUDED.base_price_usd, base_price_try=EXCLUDED.base_price_try, last_pricing_date=EXCLUDED.last_pricing_date, updated_at=now() `, strings.TrimSpace(code), strings.TrimSpace(itemDescription), strings.TrimSpace(kategori), askiliYan, urunIlkGrubu, strings.TrimSpace(urunAnaGrubu), strings.TrimSpace(urunAltGrubu), cost, usd, tryPrice, lp); err != nil { return err } count++ if count%5000 == 0 { log.Printf("[ProductPerformanceRefresh] price upserted rows=%d", count) } } log.Printf("[ProductPerformanceRefresh] price upsert done rows=%d", count) return rows.Err() } func RebuildProductPerformanceKPI(ctx context.Context, exec interface { ExecContext(context.Context, string, ...any) (sql.Result, error) }, kpiDate time.Time) (int, error) { kpiDate = dateOnly(kpiDate) if _, err := exec.ExecContext(ctx, `DELETE FROM mk_product_performance_kpi_daily WHERE kpi_date=$1`, kpiDate); err != nil { return 0, err } res, err := exec.ExecContext(ctx, productPerformanceKPISQL(), kpiDate) if err != nil { return 0, err } if _, err := exec.ExecContext(ctx, ` UPDATE mk_product_performance_kpi_daily k SET item_description = CASE WHEN btrim(COALESCE(k.item_description,'')) = '' THEN COALESCE(NULLIF(p.item_description,''), k.item_description) ELSE k.item_description END, kategori = CASE WHEN btrim(COALESCE(k.kategori,'')) = '' OR btrim(COALESCE(k.kategori,'')) = '-' THEN COALESCE(NULLIF(p.kategori,''), '') ELSE k.kategori END, askili_yan = CASE WHEN btrim(COALESCE(k.askili_yan,'')) = '' OR btrim(COALESCE(k.askili_yan,'')) = '-' THEN COALESCE(NULLIF(NULLIF(p.askili_yan,''), '-'), '') ELSE k.askili_yan END, urun_ilk_grubu = CASE WHEN btrim(COALESCE(k.urun_ilk_grubu,'')) = '' OR btrim(COALESCE(k.urun_ilk_grubu,'')) = '-' THEN COALESCE(NULLIF(p.urun_ilk_grubu,''), '') ELSE k.urun_ilk_grubu END, urun_ana_grubu = CASE WHEN btrim(COALESCE(k.urun_ana_grubu,'')) = '' THEN COALESCE(NULLIF(p.urun_ana_grubu,''), k.urun_ana_grubu) ELSE k.urun_ana_grubu END, urun_alt_grubu = CASE WHEN btrim(COALESCE(k.urun_alt_grubu,'')) = '' THEN COALESCE(NULLIF(p.urun_alt_grubu,''), k.urun_alt_grubu) ELSE k.urun_alt_grubu END, updated_at = now() FROM mk_product_performance_price_dim p WHERE k.kpi_date = $1 AND p.product_code = k.product_code AND ( btrim(COALESCE(k.item_description,'')) = '' OR btrim(COALESCE(k.kategori,'')) IN ('', '-') OR btrim(COALESCE(k.askili_yan,'')) IN ('', '-') OR btrim(COALESCE(k.urun_ilk_grubu,'')) IN ('', '-') OR btrim(COALESCE(k.urun_ana_grubu,'')) = '' OR btrim(COALESCE(k.urun_alt_grubu,'')) = '' ) `, kpiDate); err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil } func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanceFilters) ([]models.ProductPerformanceRow, int, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, 0, err } limit := f.Limit if limit <= 0 { limit = 100 } else if limit > 50000 { limit = 50000 } page := f.Page if page <= 0 { page = 1 } where, args := productPerformanceWhere(f) countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where var total int if err := pg.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, err } args = append(args, limit, (page-1)*limit) orderBy := productPerformanceOrderBy(f.SortBy, f.Descending) rows, err := pg.QueryContext(ctx, ` SELECT to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d, stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try, gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d, unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d, price_index_90d, margin_index_90d, performance_score, performance_bucket, recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number, to_char(updated_at,'YYYY-MM-DD HH24:MI:SS') FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+` ORDER BY `+orderBy+` LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) if err != nil { return nil, 0, err } defer rows.Close() out := make([]models.ProductPerformanceRow, 0, limit) for rows.Next() { var r models.ProductPerformanceRow if err := rows.Scan( &r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty, &r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.AvgDailySales90, &r.AvgDailySales180, &r.StockDays90, &r.StockDays180, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY, &r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180, &r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180, &r.MarketCount90, &r.CustomerCount90, &r.SalesIndex90, &r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt, ); err != nil { return nil, 0, err } r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) out = append(out, r) } return out, total, rows.Err() } func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return models.ProductPerformanceSummary{}, err } var s models.ProductPerformanceSummary err := pg.QueryRowContext(ctx, ` WITH Latest AS ( SELECT MAX(kpi_date) AS kpi_date FROM mk_product_performance_kpi_daily ), KPI AS ( SELECT * FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) ), VariantStock AS ( SELECT product_code, color_code, yaka_kodu, MAX(stock_qty) AS stock_qty, MAX(cost_price_usd) AS cost_price_usd, BOOL_OR(performance_bucket IN ('STOK_RISKI','TAKIP')) AS has_risk_bucket, SUM(COALESCE(sales_qty_90d,0)) AS sales_qty_90d FROM KPI GROUP BY product_code, color_code, yaka_kodu ), StockTotals AS ( SELECT COALESCE(SUM(stock_qty),0) AS total_stock, COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_usd, COALESCE(SUM(CASE WHEN has_risk_bucket AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_cost_usd FROM VariantStock ), Sales90 AS ( SELECT COUNT(DISTINCT market_key) AS market_count_90d, 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) ) SELECT COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), COUNT(*), COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'), COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'), COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'), COALESCE(MAX(StockTotals.total_stock),0), COALESCE(MAX(StockTotals.stock_cost_usd),0), COALESCE(MAX(StockTotals.risk_cost_usd),0), COALESCE(SUM(sales_qty_90d),0), COALESCE(SUM(sales_usd_90d),0), COALESCE(SUM(gross_profit_usd_90d),0), COALESCE(MAX(Sales90.market_count_90d),0)::integer, COALESCE(MAX(Sales90.customer_count_90d),0)::integer, COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'') FROM KPI CROSS JOIN Sales90 CROSS JOIN StockTotals `).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt) return s, err } func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceGeneralRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 { limit = 500 } else if limit > 50000 { limit = 50000 } rows, err := pg.QueryContext(ctx, ` WITH Bounds AS ( SELECT DATE '2022-01-01' AS period_start, COALESCE( (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily), (SELECT MAX(sales_date) FROM mk_product_performance_sales_daily), current_date ) AS period_end ), LatestStockDate AS ( SELECT MAX(stock_date) AS stock_date FROM mk_product_performance_stock_daily ), SalesAgg AS ( SELECT product_code, color_code, yaka_kodu, market_key, MAX(item_description) AS item_description, MAX(kategori) AS kategori, MAX(seri) AS seri, MAX(yas_grubu) AS yas_grubu, COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan, MAX(urun_ilk_grubu) AS urun_ilk_grubu, MAX(urun_ana_grubu) AS urun_ana_grubu, MAX(urun_alt_grubu) AS urun_alt_grubu, MIN(sales_date) AS first_sale_date, MAX(sales_date) AS last_sale_date, MAX(last_ref_number) AS last_ref_number, COALESCE(SUM(sales_qty),0) AS sales_qty_total, COALESCE(SUM(sales_usd),0) AS sales_usd_total, COALESCE(COUNT(DISTINCT NULLIF(customer_code, '-')),0)::integer AS customer_count_total, 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 GROUP BY product_code, color_code, yaka_kodu, market_key ), StockAgg AS ( SELECT s.product_code, s.color_code, s.yaka_kodu, COALESCE(SUM(s.stock_qty),0) AS stock_qty FROM mk_product_performance_stock_daily s WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate) GROUP BY s.product_code, s.color_code, s.yaka_kodu ), Dim AS ( SELECT DISTINCT ON (product_code, color_code, yaka_kodu) product_code, color_code, yaka_kodu, item_description, COALESCE(kategori,'') AS kategori, seri, yas_grubu, CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu FROM ( SELECT product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, 1 AS src_rank FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) UNION ALL SELECT product_code, color_code, yaka_kodu, MAX(item_description) AS item_description, MAX(kategori) AS kategori, MAX(seri) AS seri, MAX(yas_grubu) AS yas_grubu, COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan, MAX(urun_ilk_grubu) AS urun_ilk_grubu, MAX(urun_ana_grubu) AS urun_ana_grubu, MAX(urun_alt_grubu) AS urun_alt_grubu, 2 AS src_rank FROM mk_product_performance_sales_daily GROUP BY product_code, color_code, yaka_kodu ) x ORDER BY product_code, color_code, yaka_kodu, src_rank ), StockOnly AS ( SELECT s.product_code, s.color_code, s.yaka_kodu, 'STOK'::text AS market_key, COALESCE(MAX(d.item_description),'') AS item_description, COALESCE(MAX(d.kategori),'') AS kategori, COALESCE(MAX(d.seri),'') AS seri, COALESCE(MAX(d.yas_grubu),'') AS yas_grubu, COALESCE(MAX(NULLIF(d.askili_yan, '-')),'') AS askili_yan, COALESCE(MAX(d.urun_ilk_grubu),'') AS urun_ilk_grubu, COALESCE(MAX(d.urun_ana_grubu),'') AS urun_ana_grubu, COALESCE(MAX(d.urun_alt_grubu),'') AS urun_alt_grubu, NULL::date AS first_sale_date, NULL::date AS last_sale_date, ''::text AS last_ref_number, 0::numeric AS sales_qty_total, 0::numeric AS sales_usd_total, 0::integer AS customer_count_total, 0::integer AS invoice_count_total FROM mk_product_performance_stock_daily s LEFT JOIN Dim d ON d.product_code = s.product_code AND d.color_code = s.color_code AND d.yaka_kodu = s.yaka_kodu WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate) AND NOT EXISTS ( SELECT 1 FROM SalesAgg a WHERE a.product_code = s.product_code AND a.color_code = s.color_code AND a.yaka_kodu = s.yaka_kodu ) GROUP BY s.product_code, s.color_code, s.yaka_kodu ), BaseRows AS ( SELECT * FROM SalesAgg UNION ALL SELECT * FROM StockOnly ), Spread AS ( SELECT product_code, color_code, yaka_kodu, COUNT(DISTINCT NULLIF(market_key, 'STOK'))::integer AS market_count_total, 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 GROUP BY product_code, color_code, yaka_kodu ), Scored AS ( SELECT Bounds.period_start, Bounds.period_end, b.product_code, b.color_code, b.yaka_kodu, b.item_description, b.kategori, b.seri, b.yas_grubu, b.askili_yan, b.urun_ilk_grubu, b.urun_ana_grubu, b.urun_alt_grubu, b.market_key, COALESCE(st.stock_qty, 0) AS stock_qty, b.sales_qty_total, b.sales_usd_total, b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)) AS avg_daily_sales_total, CASE WHEN b.sales_qty_total <= 0 THEN 9999 ELSE COALESCE(st.stock_qty, 0) / NULLIF(b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)), 0) END AS stock_days_total, CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE b.sales_usd_total / NULLIF(b.sales_qty_total,0) END AS avg_price_usd_total, COALESCE(pd.cost_price_usd,0) AS cost_price_usd, COALESCE(pd.base_price_usd,0) AS base_price_usd, b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0)) AS gross_profit_usd_total, CASE WHEN b.sales_usd_total <= 0 THEN 0 ELSE (b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0))) / NULLIF(b.sales_usd_total,0) END AS gross_margin_total, CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.cost_price_usd,0) END AS unit_profit_cost_total, CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.base_price_usd,0) END AS unit_profit_base_total, COALESCE(sp.market_count_total,0) AS market_count_total, COALESCE(sp.customer_count_total_all, b.customer_count_total) AS customer_count_total, b.invoice_count_total, CASE WHEN AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu) <= 0 THEN 0 ELSE b.sales_qty_total / NULLIF(AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu),0) END AS sales_index_total, b.first_sale_date, b.last_sale_date, b.last_ref_number FROM BaseRows b CROSS JOIN Bounds LEFT JOIN StockAgg st ON st.product_code = b.product_code AND st.color_code = b.color_code AND st.yaka_kodu = b.yaka_kodu LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = b.product_code LEFT JOIN Spread sp ON sp.product_code = b.product_code AND sp.color_code = b.color_code AND sp.yaka_kodu = b.yaka_kodu ) SELECT to_char(period_start,'YYYY-MM-DD'), to_char(period_end,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty, sales_qty_total, sales_usd_total, avg_daily_sales_total, stock_days_total, avg_price_usd_total, cost_price_usd, base_price_usd, gross_profit_usd_total, gross_margin_total, unit_profit_cost_total, unit_profit_base_total, market_count_total, customer_count_total, invoice_count_total, sales_index_total, ROUND(( LEAST(35, GREATEST(0, sales_index_total) * 18) + LEAST(25, GREATEST(0, gross_margin_total) * 50) + LEAST(20, customer_count_total * 1.5) + LEAST(10, market_count_total * 2.5) + CASE WHEN sales_qty_total > 0 AND stock_days_total BETWEEN 20 AND 180 THEN 10 WHEN sales_qty_total > 0 AND stock_days_total > 365 THEN -10 WHEN sales_qty_total = 0 AND stock_qty > 0 THEN -15 ELSE 0 END )::numeric, 4) AS performance_score, CASE WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI' WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI' WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'YILDIZ_URUN' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI' ELSE 'TAKIP' END AS performance_bucket, CASE WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.' WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.' WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.' ELSE 'Izleme ve piyasa bazli aksiyon.' END AS recommendation, COALESCE(to_char(first_sale_date,'YYYY-MM-DD'),''), COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), COALESCE(last_ref_number,'') FROM Scored ORDER BY performance_score DESC, sales_usd_total DESC, product_code ASC, color_code ASC, yaka_kodu ASC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceGeneralRow, 0, limit) for rows.Next() { var r models.ProductPerformanceGeneralRow if err := rows.Scan( &r.PeriodStart, &r.PeriodEnd, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.AvgDailySalesTotal, &r.StockDaysTotal, &r.AvgPriceUSDTotal, &r.CostPriceUSD, &r.BasePriceUSD, &r.GrossProfitUSDTotal, &r.GrossMarginTotal, &r.UnitProfitCostTotal, &r.UnitProfitBaseTotal, &r.MarketCountTotal, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesIndexTotal, &r.PerformanceScore, &r.PerformanceBucket, &r.Recommendation, &r.FirstSaleDate, &r.LastSaleDate, &r.LastRefNumber, ); err != nil { return nil, err } r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) out = append(out, r) } return out, rows.Err() } func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") } if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 { limit = 500 } else if limit > 50000 { limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT OrderDate = CAST(h.OrderDate AS date), DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date), h.OrderHeaderID, h.OrderNumber, h.CurrAccCode, ProductCode = LTRIM(RTRIM(l.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))), ItemDescription = dbo.HG_Temizlik(ISNULL(( SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR' ), SPACE(0))), Kategori = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode AND cdItemAttributeDesc.AttributeTypeCode = 44 AND cdItemAttributeDesc.AttributeCode = ( SELECT TOP 1 AttributeCode FROM prItemAttribute WITH(NOLOCK) WHERE AttributeTypeCode = 44 AND prItemAttribute.ItemTypeCode = l.ItemTypeCode AND prItemAttribute.ItemCode = l.ItemCode ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), Seri = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode AND cdItemAttributeDesc.AttributeTypeCode = 2 AND cdItemAttributeDesc.AttributeCode = ( SELECT TOP 1 AttributeCode FROM prItemAttribute WITH(NOLOCK) WHERE AttributeTypeCode = 2 AND prItemAttribute.ItemTypeCode = l.ItemTypeCode AND prItemAttribute.ItemCode = l.ItemCode ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), YasGrubu = '', UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode AND cdItemAttributeDesc.AttributeTypeCode = 42 AND cdItemAttributeDesc.AttributeCode = ( SELECT TOP 1 AttributeCode FROM prItemAttribute WITH(NOLOCK) WHERE AttributeTypeCode = 42 AND prItemAttribute.ItemTypeCode = l.ItemTypeCode AND prItemAttribute.ItemCode = l.ItemCode ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), AskiliYan = dbo.HG_Temizlik(ISNULL(( SELECT TOP 1 ProductAtt45 FROM ProductAttributesFilter WITH(NOLOCK) WHERE ProductAttributesFilter.ItemCode = l.ItemCode ), SPACE(0))), UrunAnaGrubu = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode AND cdItemAttributeDesc.AttributeTypeCode = 1 AND cdItemAttributeDesc.AttributeCode = ( SELECT TOP 1 AttributeCode FROM prItemAttribute WITH(NOLOCK) WHERE AttributeTypeCode = 1 AND prItemAttribute.ItemTypeCode = l.ItemTypeCode AND prItemAttribute.ItemCode = l.ItemCode ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), UrunAltGrubu = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode AND cdItemAttributeDesc.AttributeTypeCode = 2 AND cdItemAttributeDesc.AttributeCode = ( SELECT TOP 1 AttributeCode FROM prItemAttribute WITH(NOLOCK) WHERE AttributeTypeCode = 2 AND prItemAttribute.ItemTypeCode = l.ItemTypeCode AND prItemAttribute.ItemCode = l.ItemCode ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), MarketKey = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR' ), SPACE(0))), Qty = ISNULL(l.Qty1, 0), AmountUSD = CASE WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0) WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate ELSE 0 END FROM dbo.trOrderHeader h WITH(NOLOCK) INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY') LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode OUTER APPLY ( SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC ) usd OUTER APPLY ( SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC ) cur WHERE ISNULL(h.IsCancelOrder, 0) = 0 AND ISNULL(h.IsClosed, 0) = 0 AND h.OrderTypeCode = 1 AND h.ProcessCode = 'WS' AND ISNULL(l.IsClosed, 0) = 0 AND l.ItemTypeCode = 1 AND ISNULL(l.Qty1, 0) > 0 AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13 AND ( l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%' ) ), Spread AS ( SELECT ProductCode, ColorCode, YakaKodu, MarketCount = COUNT(DISTINCT NULLIF(MarketKey, '')), CustomerCount = COUNT(DISTINCT NULLIF(CurrAccCode, '')) FROM OpenOrderLines GROUP BY ProductCode, ColorCode, YakaKodu ) SELECT TOP (@p1) o.ProductCode, o.ColorCode, o.YakaKodu, ItemDescription = MAX(o.ItemDescription), Kategori = MAX(o.Kategori), Seri = MAX(o.Seri), YasGrubu = MAX(o.YasGrubu), AskiliYan = ISNULL(NULLIF(MAX(o.AskiliYan), '-'), ''), UrunIlkGrubu = MAX(o.UrunIlkGrubu), UrunAnaGrubu = MAX(o.UrunAnaGrubu), UrunAltGrubu = MAX(o.UrunAltGrubu), o.MarketKey, OrderQty = SUM(o.Qty), OrderUSD = SUM(o.AmountUSD), AvgOrderPriceUSD = CASE WHEN SUM(o.Qty) = 0 THEN 0 ELSE SUM(o.AmountUSD) / NULLIF(SUM(o.Qty), 0) END, MarketCount = MAX(s.MarketCount), CustomerCount = COUNT(DISTINCT NULLIF(o.CurrAccCode, '')), OrderCount = COUNT(DISTINCT o.OrderHeaderID), LineCount = COUNT(*), FirstOrderDate = CONVERT(varchar, MIN(o.OrderDate), 23), LastOrderDate = CONVERT(varchar, MAX(o.OrderDate), 23), EarliestDueDate = CONVERT(varchar, MIN(o.DueDate), 23), OverdueQty = SUM(CASE WHEN o.DueDate < CAST(GETDATE() AS date) THEN o.Qty ELSE 0 END) FROM OpenOrderLines o LEFT JOIN Spread s ON s.ProductCode = o.ProductCode AND s.ColorCode = o.ColorCode AND s.YakaKodu = o.YakaKodu GROUP BY o.ProductCode, o.ColorCode, o.YakaKodu, o.MarketKey ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.YakaKodu `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceOrderAnalysisRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { var r models.ProductPerformanceOrderAnalysisRow if err := rows.Scan( &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.MarketCount, &r.CustomerCount, &r.OrderCount, &r.LineCount, &r.FirstOrderDate, &r.LastOrderDate, &r.EarliestDueDate, &r.OverdueQty, ); err != nil { return nil, err } r.AskiliYan = cleanProductPerformanceOptionalAttr(r.AskiliYan) out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true productCodes = append(productCodes, r.ProductCode) } key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu) if !seenStockKeys[key] { seenStockKeys[key] = true stockKeys = append(stockKeys, key) } } if err := rows.Err(); err != nil { return nil, err } priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes) if err != nil { return nil, err } stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys) if err != nil { return nil, err } attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys) if err != nil { return nil, err } for i := range out { row := &out[i] price := priceByProduct[row.ProductCode] attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] if strings.TrimSpace(row.ItemDescription) == "" { row.ItemDescription = attr.itemDescription } if strings.TrimSpace(row.Kategori) == "" { row.Kategori = attr.kategori } row.AskiliYan = cleanProductPerformanceOptionalAttr(row.AskiliYan) if strings.TrimSpace(row.AskiliYan) == "" { row.AskiliYan = cleanProductPerformanceOptionalAttr(attr.askiliYan) } if strings.TrimSpace(row.UrunIlkGrubu) == "" { row.UrunIlkGrubu = attr.urunIlkGrubu } if strings.TrimSpace(row.UrunAnaGrubu) == "" { row.UrunAnaGrubu = attr.urunAnaGrubu } if strings.TrimSpace(row.UrunAltGrubu) == "" { row.UrunAltGrubu = attr.urunAltGrubu } row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] row.NetStockAfterOrder = row.StockQty - row.OrderQty row.UnitProfitCostUSD = row.AvgOrderPriceUSD - row.CostPriceUSD row.UnitProfitBaseUSD = row.AvgOrderPriceUSD - row.BasePriceUSD row.ExpectedProfitCostUSD = row.UnitProfitCostUSD * row.OrderQty row.ExpectedProfitBaseUSD = row.UnitProfitBaseUSD * row.OrderQty if row.OrderUSD != 0 { row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } row.PerformanceBucket, row.Recommendation = productPerformanceOrderRecommendation(*row) } return out, nil } func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceOrderGroupRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") } if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } mode := strings.ToLower(strings.TrimSpace(breakdown)) if mode != "customer" { mode = "market" } if limit <= 0 || limit > 1000 { limit = 500 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT h.OrderHeaderID, h.CurrAccCode, CustomerName = ISNULL(cad.CurrAccDescription, ''), ProductCode = LTRIM(RTRIM(l.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))), MarketKey = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR' ), SPACE(0))), Qty = ISNULL(l.Qty1, 0), AmountUSD = CASE WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0) WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate ELSE 0 END, DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date) FROM dbo.trOrderHeader h WITH(NOLOCK) INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY') LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR' OUTER APPLY ( SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC ) usd OUTER APPLY ( SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC ) cur WHERE ISNULL(h.IsCancelOrder, 0) = 0 AND ISNULL(h.IsClosed, 0) = 0 AND h.OrderTypeCode = 1 AND h.ProcessCode = 'WS' AND ISNULL(l.IsClosed, 0) = 0 AND l.ItemTypeCode = 1 AND ISNULL(l.Qty1, 0) > 0 AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13 AND ( l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%' ) ) SELECT TOP (@p1) ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName, OrderQty = SUM(Qty), OrderUSD = SUM(AmountUSD), OrderCount = COUNT(DISTINCT OrderHeaderID), LineCount = COUNT(*), OverdueQty = SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END) FROM OpenOrderLines GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC `, limit*4) if err != nil { return nil, err } defer rows.Close() type detail struct { productCode string colorCode string yakaKodu string marketKey string customerCode string customerName string orderQty float64 orderUSD float64 orderCount int lineCount int overdueQty float64 } details := make([]detail, 0, limit*2) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { var d detail if err := rows.Scan(&d.productCode, &d.colorCode, &d.yakaKodu, &d.marketKey, &d.customerCode, &d.customerName, &d.orderQty, &d.orderUSD, &d.orderCount, &d.lineCount, &d.overdueQty); err != nil { return nil, err } details = append(details, d) if !seenProducts[d.productCode] { seenProducts[d.productCode] = true productCodes = append(productCodes, d.productCode) } key := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu) if !seenStockKeys[key] { seenStockKeys[key] = true stockKeys = append(stockKeys, key) } } if err := rows.Err(); err != nil { return nil, err } priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes) if err != nil { return nil, err } stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys) if err != nil { return nil, err } grouped := map[string]*models.ProductPerformanceOrderGroupRow{} productSets := map[string]map[string]bool{} marketSets := map[string]map[string]bool{} customerSets := map[string]map[string]bool{} stockVariantSets := map[string]map[string]bool{} for _, d := range details { groupKey := strings.TrimSpace(d.marketKey) customerCode := "" customerName := "" if mode == "customer" { groupKey = strings.TrimSpace(d.customerCode) customerCode = strings.TrimSpace(d.customerCode) customerName = strings.TrimSpace(d.customerName) } if groupKey == "" { groupKey = "-" } row := grouped[groupKey] if row == nil { row = &models.ProductPerformanceOrderGroupRow{ Breakdown: mode, GroupKey: groupKey, MarketKey: strings.TrimSpace(d.marketKey), CustomerCode: customerCode, CustomerName: customerName, } grouped[groupKey] = row productSets[groupKey] = map[string]bool{} marketSets[groupKey] = map[string]bool{} customerSets[groupKey] = map[string]bool{} stockVariantSets[groupKey] = map[string]bool{} } price := priceByProduct[d.productCode] variantKey := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu) row.OrderQty += d.orderQty row.OrderUSD += d.orderUSD row.BaseCostUSD += d.orderQty * price.base row.CostAmountUSD += d.orderQty * price.cost if !stockVariantSets[groupKey][variantKey] { row.StockQty += stockByKey[variantKey] stockVariantSets[groupKey][variantKey] = true } row.OrderCount += d.orderCount row.LineCount += d.lineCount row.OverdueQty += d.overdueQty productSets[groupKey][d.productCode] = true if strings.TrimSpace(d.marketKey) != "" { marketSets[groupKey][strings.TrimSpace(d.marketKey)] = true } if strings.TrimSpace(d.customerCode) != "" { customerSets[groupKey][strings.TrimSpace(d.customerCode)] = true } } out := make([]models.ProductPerformanceOrderGroupRow, 0, len(grouped)) for key, row := range grouped { row.ProductCount = len(productSets[key]) row.MarketCount = len(marketSets[key]) row.CustomerCount = len(customerSets[key]) row.NetStockAfterOrder = row.StockQty - row.OrderQty if row.OrderQty != 0 { row.AvgOrderPriceUSD = row.OrderUSD / row.OrderQty } row.ExpectedProfitBaseUSD = row.OrderUSD - row.BaseCostUSD row.ExpectedProfitCostUSD = row.OrderUSD - row.CostAmountUSD if row.OrderUSD != 0 { row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } row.PerformanceBucket, row.Recommendation = productPerformanceOrderGroupRecommendation(*row) out = append(out, *row) } sort.Slice(out, func(i, j int) bool { if out[i].ExpectedProfitCostUSD < 0 && out[j].ExpectedProfitCostUSD >= 0 { return true } if out[i].ExpectedProfitCostUSD >= 0 && out[j].ExpectedProfitCostUSD < 0 { return false } return out[i].OrderUSD > out[j].OrderUSD }) if len(out) > limit { out = out[:limit] } return out, nil } func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderProductCustomerRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") } if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 || limit > 1000 { limit = 500 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT h.OrderHeaderID, h.CurrAccCode, CustomerName = ISNULL(cad.CurrAccDescription, ''), ProductCode = LTRIM(RTRIM(l.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))), ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))), MarketKey = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR' ), SPACE(0))), Qty = ISNULL(l.Qty1, 0), AmountUSD = CASE WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0) WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate ELSE 0 END, DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date) FROM dbo.trOrderHeader h WITH(NOLOCK) INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY') LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR' OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur WHERE ISNULL(h.IsCancelOrder, 0) = 0 AND ISNULL(h.IsClosed, 0) = 0 AND h.OrderTypeCode = 1 AND h.ProcessCode = 'WS' AND ISNULL(l.IsClosed, 0) = 0 AND l.ItemTypeCode = 1 AND ISNULL(l.Qty1, 0) > 0 AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13 AND (l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%') ) SELECT TOP (@p1) ProductCode, ColorCode, YakaKodu, MAX(ItemDescription), MarketKey, CurrAccCode, CustomerName, SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END, COUNT(DISTINCT OrderHeaderID), COUNT(*), SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END) FROM OpenOrderLines GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceOrderProductCustomerRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { var r models.ProductPerformanceOrderProductCustomerRow if err := rows.Scan(&r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.OrderCount, &r.LineCount, &r.OverdueQty); err != nil { return nil, err } out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true productCodes = append(productCodes, r.ProductCode) } key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu) if !seenStockKeys[key] { seenStockKeys[key] = true stockKeys = append(stockKeys, key) } } if err := rows.Err(); err != nil { return nil, err } priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes) if err != nil { return nil, err } stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys) if err != nil { return nil, err } attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys) if err != nil { return nil, err } for i := range out { row := &out[i] price := priceByProduct[row.ProductCode] attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] if strings.TrimSpace(row.ItemDescription) == "" { row.ItemDescription = attr.itemDescription } row.Kategori = attr.kategori row.AskiliYan = cleanProductPerformanceOptionalAttr(attr.askiliYan) row.UrunIlkGrubu = attr.urunIlkGrubu row.UrunAnaGrubu = attr.urunAnaGrubu row.UrunAltGrubu = attr.urunAltGrubu row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] row.NetStockAfterOrder = row.StockQty - row.OrderQty row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD if row.OrderUSD != 0 { row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } row.PerformanceBucket, row.Recommendation = productPerformanceOrderProductCustomerRecommendation(*row) } return out, nil } func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderMarketDetailRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") } if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 { limit = 800 } else if limit > 50000 { limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT MarketKey = dbo.HG_Temizlik(ISNULL((SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR'), SPACE(0))), h.CurrAccCode, CustomerName = ISNULL(cad.CurrAccDescription, ''), h.OrderNumber, OrderDate = CAST(h.OrderDate AS date), DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date), ProductCode = LTRIM(RTRIM(l.ItemCode)), ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))), YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))), ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))), Qty = ISNULL(l.Qty1, 0), AmountUSD = CASE WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0) WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate ELSE 0 END FROM dbo.trOrderHeader h WITH(NOLOCK) INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY') LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR' OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur WHERE ISNULL(h.IsCancelOrder, 0) = 0 AND ISNULL(h.IsClosed, 0) = 0 AND h.OrderTypeCode = 1 AND h.ProcessCode = 'WS' AND ISNULL(l.IsClosed, 0) = 0 AND l.ItemTypeCode = 1 AND ISNULL(l.Qty1, 0) > 0 AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13 AND (l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%') ) SELECT TOP (@p1) MarketKey, CurrAccCode, CustomerName, OrderNumber, CONVERT(varchar, OrderDate, 23), CONVERT(varchar, DueDate, 23), ProductCode, ColorCode, YakaKodu, MAX(ItemDescription), SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END, CASE WHEN MIN(DueDate) < CAST(GETDATE() AS date) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END FROM OpenOrderLines GROUP BY MarketKey, CurrAccCode, CustomerName, OrderNumber, OrderDate, DueDate, ProductCode, ColorCode, YakaKodu ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DESC `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceOrderMarketDetailRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { var r models.ProductPerformanceOrderMarketDetailRow if err := rows.Scan(&r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderNumber, &r.OrderDate, &r.DueDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.IsOverdue); err != nil { return nil, err } out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true productCodes = append(productCodes, r.ProductCode) } key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu) if !seenStockKeys[key] { seenStockKeys[key] = true stockKeys = append(stockKeys, key) } } if err := rows.Err(); err != nil { return nil, err } priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes) if err != nil { return nil, err } stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys) if err != nil { return nil, err } for i := range out { row := &out[i] price := priceByProduct[row.ProductCode] row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] row.NetStockAfterOrder = row.StockQty - row.OrderQty row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD if row.OrderUSD != 0 { row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } row.PerformanceBucket, row.Recommendation = productPerformanceOrderMarketDetailRecommendation(*row) } return out, nil } func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 || limit > 500 { limit = 100 } rows, err := pg.QueryContext(ctx, ` SELECT market_key, MAX(kategori) AS kategori, MAX(seri) AS seri, MAX(yas_grubu) AS yas_grubu, COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan, MAX(urun_ilk_grubu) AS urun_ilk_grubu, MAX(urun_ana_grubu) AS urun_ana_grubu, MAX(urun_alt_grubu) AS urun_alt_grubu, COUNT(DISTINCT product_code) AS product_count, COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN') AS star_count, COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI') AS stock_risk_count, COALESCE(SUM(stock_qty),0) AS stock_qty, COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_value_usd, COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_stock_cost_value_usd, COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d, COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d, COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, COALESCE(AVG(NULLIF(gross_margin_90d,0)),0) AS avg_gross_margin_90d, 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) GROUP BY market_key ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceMarketRow, 0, limit) for rows.Next() { var r models.ProductPerformanceMarketRow if err := rows.Scan( &r.MarketKey, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.ProductCount, &r.StarCount, &r.StockRiskCount, &r.StockQty, &r.StockCostValueUSD, &r.RiskStockCostValueUSD, &r.SalesQty90, &r.SalesUSD90, &r.GrossProfitUSD90, &r.AvgGrossMargin90, &r.AvgStockDays90, ); err != nil { return nil, err } r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) out = append(out, r) } return out, rows.Err() } func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceCountryRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 || limit > 500 { limit = 100 } rows, err := pg.QueryContext(ctx, ` SELECT customer_country, customer_segment, market_key, MAX(kategori) AS kategori, MAX(seri) AS seri, COUNT(DISTINCT product_code) AS product_count, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) END AS avg_price_usd_90d, COALESCE(SUM(customer_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS customer_count_90d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d, 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' GROUP BY customer_country, customer_segment, market_key ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceCountryRow, 0, limit) for rows.Next() { var r models.ProductPerformanceCountryRow if err := rows.Scan( &r.Country, &r.CustomerSegment, &r.MarketKey, &r.Kategori, &r.Seri, &r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty365, &r.SalesUSD365, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceCustomerRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 || limit > 500 { limit = 100 } mode := strings.ToLower(strings.TrimSpace(breakdown)) if mode == "" { mode = "market_customer" } selectMarket := "''" selectCountry := "''" selectSegment := "''" groupCols := []string{"customer_code", "customer_name"} switch mode { case "country_customer": selectCountry = "customer_country" selectSegment = "customer_segment" groupCols = append(groupCols, "customer_country", "customer_segment") case "market_country_customer": selectMarket = "market_key" selectCountry = "customer_country" selectSegment = "customer_segment" groupCols = append(groupCols, "market_key", "customer_country", "customer_segment") default: mode = "market_customer" selectMarket = "market_key" groupCols = append(groupCols, "market_key") } query := fmt.Sprintf(` SELECT $2::text AS breakdown, %s AS market_key, %s AS country, %s AS customer_segment, customer_code, customer_name, COUNT(DISTINCT product_code) AS product_count, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) END AS avg_price_usd_90d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d, COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date FROM mk_product_performance_sales_daily WHERE sales_date >= current_date - INTERVAL '364 days' AND COALESCE(customer_code,'') <> '' GROUP BY %s ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 `, selectMarket, selectCountry, selectSegment, strings.Join(groupCols, ", ")) rows, err := pg.QueryContext(ctx, query, limit, mode) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceCustomerRow, 0, limit) for rows.Next() { var r models.ProductPerformanceCustomerRow if err := rows.Scan( &r.Breakdown, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.InvoiceCount90, &r.SalesQty365, &r.SalesUSD365, &r.LastSaleDate, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceSalesBreakdownRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } if limit <= 0 { limit = 500 } else if limit > 50000 { limit = 50000 } mode := strings.ToLower(strings.TrimSpace(breakdown)) fields := map[string]string{ "product_code": "s.product_code", "color_code": "s.color_code", "yaka_kodu": "s.yaka_kodu", "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_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')", "urun_alt_grubu": "MAX(s.urun_alt_grubu)", "market_key": "s.market_key", "country": "s.customer_country", "customer_segment": "s.customer_segment", "customer_code": "s.customer_code", "customer_name": "MAX(s.customer_name)", } selected := map[string]bool{} groupCols := []string{} switch mode { case "color_yaka_market_customer": selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true selected["kategori"] = true selected["askili_yan"] = true selected["urun_ilk_grubu"] = true selected["urun_ana_grubu"] = true selected["urun_alt_grubu"] = true selected["country"] = true selected["customer_segment"] = true 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"} case "product_country_segment_market_customer": selected["product_code"] = true selected["item_description"] = true selected["kategori"] = true selected["askili_yan"] = true selected["urun_ilk_grubu"] = true selected["urun_ana_grubu"] = true selected["urun_alt_grubu"] = true selected["color_code"] = true selected["yaka_kodu"] = true selected["country"] = true selected["customer_segment"] = true selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu", "s.customer_country", "s.customer_segment"} case "market_customer_product": selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true selected["kategori"] = true selected["askili_yan"] = true selected["urun_ilk_grubu"] = true selected["urun_ana_grubu"] = true selected["urun_alt_grubu"] = true groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"} case "country_segment_market_customer_product": selected["country"] = true selected["customer_segment"] = true selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true selected["kategori"] = true selected["askili_yan"] = true selected["urun_ilk_grubu"] = true selected["urun_ana_grubu"] = true selected["urun_alt_grubu"] = true groupCols = []string{"s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"} default: mode = "color_yaka_market_customer" selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true selected["kategori"] = true selected["askili_yan"] = true selected["urun_ilk_grubu"] = true selected["urun_ana_grubu"] = true selected["urun_alt_grubu"] = true selected["country"] = true selected["customer_segment"] = true 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"} } selectExpr := func(name string) string { if selected[name] { return fields[name] + " AS " + name } return "''::text AS " + name } query := fmt.Sprintf(` WITH LatestKPI AS ( SELECT DISTINCT ON (product_code, color_code, yaka_kodu) product_code, color_code, yaka_kodu, COALESCE(kategori,'') AS kategori, 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 '' 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) ORDER BY product_code, color_code, yaka_kodu, performance_score DESC ), StockAgg AS ( SELECT product_code, color_code, yaka_kodu, COALESCE(SUM(stock_qty),0) AS stock_qty FROM mk_product_performance_stock_daily WHERE stock_date = (SELECT MAX(stock_date) FROM mk_product_performance_stock_daily) GROUP BY product_code, color_code, yaka_kodu ), Agg AS ( SELECT $2::text AS breakdown, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, COUNT(DISTINCT product_code) AS product_count, COALESCE(MAX(stock_qty),0) AS stock_qty, COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_90d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) END AS avg_price_usd_90d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) END AS base_price_usd_90d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) END AS cost_price_usd_90d, COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_180d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)::integer AS invoice_count_180d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_qty_180d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_usd_180d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) END AS avg_price_usd_180d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) END AS base_price_usd_180d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) END AS cost_price_usd_180d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d, COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_365d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) END AS avg_price_usd_365d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) END AS base_price_usd_365d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) END AS cost_price_usd_365d, COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_total, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)::integer AS invoice_count_total, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_qty_total, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_usd_total, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) END AS avg_price_usd_total, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) END AS base_price_usd_total, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) END AS cost_price_usd_total, COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date FROM ( SELECT s.*, COALESCE(pd.base_price_usd,0) AS base_price_usd, COALESCE(pd.cost_price_usd,0) AS cost_price_usd, COALESCE(st.stock_qty,0) AS stock_qty FROM mk_product_performance_sales_daily s LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu ) s WHERE sales_date >= DATE '2022-01-01' GROUP BY %s ), Enriched AS ( SELECT Agg.*, sales_usd_90d - (sales_qty_90d * base_price_usd_90d) AS gross_profit_base_usd_90d, sales_usd_90d - (sales_qty_90d * cost_price_usd_90d) AS gross_profit_cost_usd_90d, CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * base_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_base_90d, CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * cost_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_cost_90d, sales_usd_180d - (sales_qty_180d * base_price_usd_180d) AS gross_profit_base_usd_180d, sales_usd_180d - (sales_qty_180d * cost_price_usd_180d) AS gross_profit_cost_usd_180d, CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * base_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_base_180d, CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * cost_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_cost_180d, sales_usd_365d - (sales_qty_365d * base_price_usd_365d) AS gross_profit_base_usd_365d, sales_usd_365d - (sales_qty_365d * cost_price_usd_365d) AS gross_profit_cost_usd_365d, CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * base_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_base_365d, CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * cost_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_cost_365d, sales_usd_total - (sales_qty_total * base_price_usd_total) AS gross_profit_base_usd_total, sales_usd_total - (sales_qty_total * cost_price_usd_total) AS gross_profit_cost_usd_total, CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * base_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_base_total, CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * cost_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_cost_total, (base_price_usd_total > 0 AND cost_price_usd_total > 0) AS has_cost FROM Agg ), Scored AS ( SELECT Enriched.*, CASE WHEN AVG(NULLIF(sales_usd_90d,0)) OVER () IS NULL THEN 0 ELSE sales_usd_90d / NULLIF(AVG(NULLIF(sales_usd_90d,0)) OVER (),0) END AS sales_index_90d FROM Enriched ) SELECT breakdown, product_code, color_code, yaka_kodu, item_description, kategori, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, country, customer_segment, customer_code, customer_name, product_count, stock_qty, customer_count_90d, invoice_count_90d, sales_qty_90d, sales_usd_90d, avg_price_usd_90d, base_price_usd_90d, cost_price_usd_90d, gross_profit_base_usd_90d, gross_profit_cost_usd_90d, gross_margin_base_90d, gross_margin_cost_90d, customer_count_180d, invoice_count_180d, sales_qty_180d, sales_usd_180d, avg_price_usd_180d, base_price_usd_180d, cost_price_usd_180d, gross_profit_base_usd_180d, gross_profit_cost_usd_180d, gross_margin_base_180d, gross_margin_cost_180d, sales_qty_365d, sales_usd_365d, avg_price_usd_365d, base_price_usd_365d, cost_price_usd_365d, gross_profit_base_usd_365d, gross_profit_cost_usd_365d, gross_margin_base_365d, gross_margin_cost_365d, customer_count_365d, invoice_count_365d, customer_count_total, invoice_count_total, sales_qty_total, sales_usd_total, avg_price_usd_total, base_price_usd_total, cost_price_usd_total, gross_profit_base_usd_total, gross_profit_cost_usd_total, gross_margin_base_total, gross_margin_cost_total, CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_90d, CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_180d, CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_365d, CASE WHEN stock_qty > 0 THEN sales_qty_total / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_total, has_cost, sales_index_90d, CASE WHEN NOT has_cost THEN 0 ELSE ROUND(( LEAST(35, sales_usd_90d / 1000) + LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55) + LEAST(20, invoice_count_90d * 2) + LEAST(10, sales_qty_90d / 10) + LEAST(10, product_count) )::numeric, 4) END AS customer_score_90d, CASE WHEN NOT has_cost THEN 0 ELSE ROUND(( LEAST(35, sales_usd_180d / 1000) + LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55) + LEAST(20, invoice_count_180d * 2) + LEAST(10, sales_qty_180d / 10) + LEAST(10, product_count) )::numeric, 4) END AS customer_score_180d, CASE WHEN NOT has_cost THEN 0 ELSE ROUND(( LEAST(35, sales_usd_365d / 1000) + LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55) + LEAST(20, invoice_count_365d * 2) + LEAST(10, sales_qty_365d / 10) + LEAST(10, product_count) )::numeric, 4) END AS customer_score_365d, CASE WHEN NOT has_cost THEN 0 ELSE ROUND(( LEAST(35, sales_usd_total / 1000) + LEAST(25, GREATEST(gross_margin_cost_total,0) * 55) + LEAST(20, invoice_count_total * 2) + LEAST(10, sales_qty_total / 10) + LEAST(10, product_count) )::numeric, 4) END AS customer_score_total, CASE WHEN NOT has_cost THEN 0 ELSE ROUND( LEAST(35, sales_index_90d * 18) + LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60) + LEAST(15, invoice_count_90d * 1.5) + LEAST(10, sales_qty_90d / 10) + LEAST(10, (CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END) * 5), 4 ) END AS performance_score, CASE WHEN NOT has_cost THEN 'MALIYET_YOK' WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'YILDIZ_URUN' WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'STOK_RISKI' WHEN gross_margin_cost_90d < 0 THEN 'FIYAT_BASKISI' WHEN sales_index_90d < 0.75 THEN 'TAKIP' 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' ELSE 'Takip' END AS recommendation, last_sale_date FROM Scored ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 `, selectExpr("product_code"), selectExpr("color_code"), selectExpr("yaka_kodu"), selectExpr("item_description"), selectExpr("kategori"), selectExpr("askili_yan"), selectExpr("urun_ilk_grubu"), selectExpr("urun_ana_grubu"), selectExpr("urun_alt_grubu"), selectExpr("market_key"), selectExpr("country"), selectExpr("customer_segment"), selectExpr("customer_code"), selectExpr("customer_name"), strings.Join(groupCols, ", "), ) rows, err := pg.QueryContext(ctx, query, limit, mode) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceSalesBreakdownRow, 0, limit) for rows.Next() { var r models.ProductPerformanceSalesBreakdownRow if err := rows.Scan( &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.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, &r.CostPriceUSD180, &r.GrossProfitBase180, &r.GrossProfitCost180, &r.GrossMarginBase180, &r.GrossMarginCost180, &r.SalesQty365, &r.SalesUSD365, &r.AvgPriceUSD365, &r.BasePriceUSD365, &r.CostPriceUSD365, &r.GrossProfitBase365, &r.GrossProfitCost365, &r.GrossMarginBase365, &r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal, &r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost, &r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } type ProductPerformanceGroupedRequest struct { Mode string GroupLevels []string ExpandedKeys map[string]bool Limit int } func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) { if req.Limit <= 0 || req.Limit > 50000 { req.Limit = 50000 } levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels) if len(levels) == 0 { levels = defaultProductPerformanceGroupLevels(req.Mode) } if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil { return out, err } sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit) if err != nil { return nil, err } out := make([]map[string]any, 0, len(sourceRows)) appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys) return out, nil } type productPerformanceSQLGroupFilter struct { Field string Value string } func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { mode := strings.TrimSpace(req.Mode) if mode != "products" && mode != "idle" { return nil, false, nil } if err := EnsureProductPerformanceTables(pg); err != nil { return nil, true, err } out := make([]map[string]any, 0, 512) err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit) return out, true, err } 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, limit int) error { if level >= len(levels) { rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit) if err != nil { return err } *out = append(*out, rows...) return nil } field := levels[level] rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters) if err != nil { return err } for _, row := range rows { value := stringFromMap(row, "group_value") if shouldSkipProductPerformanceGroupValue(field, value) { nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, limit); err != nil { return err } continue } keyPart := field + ":" + value key := strings.Join(append(parentKeys, keyPart), "|") row["row_key"] = "group|" + key row["key"] = key row["level"] = visualLevel row["group_field"] = field row["group_value"] = value row["label"] = value row["__group"] = true row[field] = value *out = append(*out, row) if expandedKeys[key] { nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil { return err } } } return nil } func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) { groupExpr, ok := productPerformanceSQLGroupExpr(field) if !ok { return nil, fmt.Errorf("unsupported product performance group field: %s", field) } whereSQL, args, err := productPerformanceSQLFilterWhere(filters) if err != nil { return nil, err } query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` SELECT jsonb_build_object( 'group_value', group_value, 'label', group_value, 'count', row_count, 'recommendation', row_count::text || ' satir', 'image_product_code', image_product_code, 'image_color_code', image_color_code, 'image_yaka_kodu', image_yaka_kodu, 'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END, 'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END, 'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END, 'item_description', item_description, 'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END, 'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END, 'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END, 'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END, 'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END, 'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END, 'stock_qty', stock_qty, 'sales_qty_90d', sales_qty_90d, 'sales_qty_180d', sales_qty_180d, 'sales_qty_365d', sales_qty_365d, 'sales_usd_90d', sales_usd_90d, 'sales_usd_180d', sales_usd_180d, 'sales_usd_365d', sales_usd_365d, 'avg_price_usd_90d', avg_price_usd_90d, 'avg_price_usd_180d', avg_price_usd_180d, 'cost_price_usd', cost_price_usd, 'base_price_usd', base_price_usd, 'base_price_try', base_price_try, 'gross_profit_usd_90d', gross_profit_usd_90d, 'gross_profit_usd_180d', gross_profit_usd_180d, 'gross_margin_90d', gross_margin_90d, 'gross_margin_180d', gross_margin_180d, 'unit_profit_cost_90d', unit_profit_cost_90d, 'unit_profit_cost_180d', unit_profit_cost_180d, 'unit_profit_base_90d', unit_profit_base_90d, 'unit_profit_base_180d', unit_profit_base_180d, 'market_count_90d', market_count_90d, 'customer_count_90d', customer_count_90d, 'sales_index_90d', sales_index_90d, 'price_index_90d', price_index_90d, 'margin_index_90d', margin_index_90d, 'performance_score', performance_score, 'performance_bucket', performance_bucket, 'idle_cost_usd', idle_cost_usd, 'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END, 'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END, 'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END ) AS row_json FROM ( SELECT COALESCE(%s, '') AS group_value, COUNT(*)::integer AS row_count, (ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code, (ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code, (ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu, COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code, COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code, COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu, COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description, COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori, COALESCE((ARRAY_AGG(NULLIF(NULLIF(askili_yan,''), '-') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan, COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu, COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu, COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu, COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key, COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) AS stock_qty, COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d, COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d, COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d, COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d, COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d, COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d, CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS cost_price_usd, CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS base_price_usd, CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS base_price_try, COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d, CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d, CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_cost_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d, COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d, COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS performance_score, MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd FROM ( SELECT Source.*, ROW_NUMBER() OVER ( PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu ORDER BY performance_score DESC NULLS LAST ) AS stock_variant_rank FROM Source %s ) s GROUP BY COALESCE(%s, '') ) g ORDER BY group_value `, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr) args = append(args, field) return queryProductPerformanceJSONRows(ctx, pg, query, args...) } func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) { whereSQL, args, err := productPerformanceSQLFilterWhere(filters) if err != nil { return nil, err } args = append(args, limit) query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` SELECT to_jsonb(t) || jsonb_build_object( 'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key, 'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END, 'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END, 'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END ) AS row_json FROM Source t %s ORDER BY performance_score DESC, sales_usd_90d DESC LIMIT $%d `, whereSQL, len(args)) return queryProductPerformanceJSONRows(ctx, pg, query, args...) } func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) { rows, err := pg.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := []map[string]any{} for rows.Next() { var raw []byte if err := rows.Scan(&raw); err != nil { return nil, err } row := map[string]any{} if err := json.Unmarshal(raw, &row); err != nil { return nil, err } out = append(out, row) } return out, rows.Err() } func productPerformanceSQLSourceCTE(mode string) string { idleWhere := "" if mode == "idle" { idleWhere = ` AND stock_qty > 0 AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)` } return ` WITH Source AS ( SELECT product_code, color_code, yaka_kodu, item_description, kategori, seri, yas_grubu, 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 '' ELSE COALESCE(urun_ilk_grubu,'') END AS urun_ilk_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, urun_alt_grubu, market_key, COALESCE(stock_qty,0) AS stock_qty, COALESCE(sales_qty_90d,0) AS sales_qty_90d, COALESCE(sales_qty_180d,0) AS sales_qty_180d, COALESCE(sales_qty_365d,0) AS sales_qty_365d, COALESCE(sales_usd_90d,0) AS sales_usd_90d, COALESCE(sales_usd_180d,0) AS sales_usd_180d, COALESCE(sales_usd_365d,0) AS sales_usd_365d, COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d, COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d, COALESCE(cost_price_usd,0) AS cost_price_usd, COALESCE(base_price_usd,0) AS base_price_usd, COALESCE(base_price_try,0) AS base_price_try, COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d, COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d, COALESCE(gross_margin_90d,0) AS gross_margin_90d, COALESCE(gross_margin_180d,0) AS gross_margin_180d, COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d, COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d, COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d, COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d, COALESCE(market_count_90d,0) AS market_count_90d, COALESCE(customer_count_90d,0) AS customer_count_90d, COALESCE(sales_index_90d,0) AS sales_index_90d, COALESCE(price_index_90d,0) AS price_index_90d, COALESCE(margin_index_90d,0) AS margin_index_90d, COALESCE(performance_score,0) AS performance_score, COALESCE(performance_bucket,'') AS performance_bucket, 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)` + 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 case "askili_yan": return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true case "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu": return field, true case "market_key": return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true default: return "", false } } func shouldSkipProductPerformanceGroupValue(field, value string) bool { switch field { case "urun_ilk_grubu", "askili_yan": return strings.TrimSpace(value) == "" default: return false } } func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) { if len(filters) == 0 { return "", nil, nil } parts := make([]string, 0, len(filters)) args := make([]any, 0, len(filters)) for _, filter := range filters { expr, ok := productPerformanceSQLGroupExpr(filter.Field) if !ok { return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field) } args = append(args, normalizeProductPerformanceGroupValue(filter.Value)) parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = $%d", expr, len(args))) } return "WHERE " + strings.Join(parts, " AND "), args, nil } func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { switch mode { case "products": rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true}) return structsToMaps(rows), err case "idle": rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true}) if err != nil { return nil, err } return productPerformanceIdleSourceRows(structsToMaps(rows)), nil case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product": rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, productPerformanceSalesBreakdownMode(mode), limit) return structsToMaps(rows), err case "order_product_customers": rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit) return structsToMaps(rows), err case "order_market_details": rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, limit) return structsToMaps(rows), err default: rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true}) return structsToMaps(rows), err } } func productPerformanceSalesBreakdownMode(mode string) string { switch strings.TrimSpace(mode) { case "sales_color_yaka_market_customer": return "color_yaka_market_customer" case "sales_product_country_segment_market_customer": return "product_country_segment_market_customer" case "sales_market_customer_product": return "market_customer_product" case "sales_country_segment_market_customer_product": return "country_segment_market_customer_product" default: return mode } } func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any { out := make([]map[string]any, 0, len(rows)) for _, row := range rows { stockQty := floatFromMap(row, "stock_qty") salesQty90 := floatFromMap(row, "sales_qty_90d") stockDays90 := floatFromMap(row, "stock_days_90d") if stockQty <= 0 { continue } if stringFromMap(row, "performance_bucket") != "STOK_RISKI" && salesQty90 != 0 && stockDays90 <= 180 { continue } next := cloneMap(row) next["idle_cost_usd"] = stockQty * floatFromMap(row, "cost_price_usd") out = append(out, next) } return out } func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool) { if level >= len(levels) { *out = append(*out, sourceRows...) return } field := levels[level] grouped := make(map[string][]map[string]any) for _, row := range sourceRows { value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field)) grouped[value] = append(grouped[value], row) } values := make([]string, 0, len(grouped)) for value := range grouped { values = append(values, value) } sort.Slice(values, func(i, j int) bool { return strings.Compare(values[i], values[j]) < 0 }) for _, value := range values { groupRows := grouped[value] if shouldSkipProductPerformanceGroupValue(field, value) { appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys) continue } keyPart := field + ":" + value key := strings.Join(append(parentKeys, keyPart), "|") *out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows)) if expandedKeys[key] { appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys) } } } func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any { row := aggregateProductPerformanceRows(rows, field) row["__group"] = true row["row_key"] = "group|" + key row["key"] = key row["level"] = level row["group_field"] = field row["group_value"] = value row["label"] = value row["count"] = len(rows) row[field] = value row["recommendation"] = fmt.Sprintf("%d satır", len(rows)) image := firstProductPerformanceImageSource(rows) row["image_product_code"] = stringFromMap(image, "product_code") row["image_color_code"] = stringFromMap(image, "color_code") row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu") return row } func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any { out := map[string]any{} for _, row := range rows { for key, value := range row { if key == "row_key" || key == "key" { continue } if key == "stock_qty" { out[key] = distinctProductPerformanceVariantStockQty(rows) continue } if key == "idle_cost_usd" { out[key] = distinctProductPerformanceVariantStockCost(rows) continue } if shouldSumProductPerformanceField(key) { out[key] = floatFromAny(out[key]) + floatFromAny(value) } else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) { out[key] = value } } } for _, row := range rows { for key := range row { if shouldAverageProductPerformanceField(key) { out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key)) } } } deriveProductPerformanceGroupMetrics(out, groupField) out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") return out } func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 { seen := map[string]bool{} total := 0.0 hasKey := false for _, row := range rows { key := productPerformanceMapVariantKey(row) if key == "" { continue } hasKey = true if seen[key] { continue } seen[key] = true total += floatFromMap(row, "stock_qty") } if hasKey { return total } for _, row := range rows { total += floatFromMap(row, "stock_qty") } return total } func distinctProductPerformanceVariantStockCost(rows []map[string]any) float64 { seen := map[string]bool{} total := 0.0 hasKey := false for _, row := range rows { key := productPerformanceMapVariantKey(row) if key == "" { continue } hasKey = true if seen[key] { continue } seen[key] = true total += floatFromMap(row, "stock_qty") * floatFromMap(row, "cost_price_usd") } if hasKey { return total } for _, row := range rows { total += floatFromMap(row, "idle_cost_usd") } return total } func productPerformanceMapVariantKey(row map[string]any) string { productCode := stringFromMap(row, "product_code") colorCode := stringFromMap(row, "color_code") yakaKodu := stringFromMap(row, "yaka_kodu") if productCode == "" && colorCode == "" && yakaKodu == "" { return "" } return productPerformanceVariantKey(productCode, colorCode, yakaKodu) } func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { for _, suffix := range []string{"90d", "180d", "365d", "total"} { sales := floatFromMap(out, "sales_usd_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix) stockQty := floatFromMap(out, "stock_qty") if stockQty > 0 { out["stock_turnover_"+suffix] = qty / stockQty } if qty > 0 { out["avg_price_usd_"+suffix] = sales / qty } 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 } if _, ok := out["gross_profit_cost_usd_"+suffix]; ok { out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales } if _, ok := out["gross_profit_usd_"+suffix]; ok { out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales } } } orderUSD := floatFromMap(out, "order_usd") if orderUSD > 0 { out["expected_margin_base"] = floatFromMap(out, "expected_profit_base_usd") / orderUSD out["expected_margin_cost"] = floatFromMap(out, "expected_profit_cost_usd") / orderUSD } orderQty := floatFromMap(out, "order_qty") if orderQty > 0 { out["avg_order_price_usd"] = orderUSD / orderQty } if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 { out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty } out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) 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"] = productPerformanceGroupScore(out, groupField) } func productPerformanceGroupScore(row map[string]any, groupField string) float64 { if floatFromMap(row, "order_qty") > 0 || floatFromMap(row, "order_usd") > 0 { return productPerformanceOrderGroupScore(row) } if isProductPerformanceCustomerGroup(groupField) { return productPerformanceCustomerSalesGroupScore(row) } return productPerformanceSalesGroupScore(row) } func isProductPerformanceCustomerGroup(groupField string) bool { switch strings.TrimSpace(groupField) { case "customer_code", "customer_name": return true default: return false } } 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") if stockTurnover == 0 { if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { 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 } func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 { scores := []float64{ floatFromMap(row, "customer_score_90d"), floatFromMap(row, "customer_score_180d"), floatFromMap(row, "customer_score_365d"), floatFromMap(row, "customer_score_total"), } total := 0.0 count := 0.0 for _, score := range scores { if score <= 0 { continue } total += score count++ } if count == 0 { return 0 } return total / count } func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 { salesUSD := floatFromMap(row, "sales_usd") margin := floatFromMap(row, "gross_margin_cost") invoiceCount := floatFromMap(row, "invoice_count") 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) } 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"), } } func productPerformanceOrderGroupScore(row map[string]any) float64 { orderUSD := floatFromMap(row, "order_usd") margin := floatFromMap(row, "expected_margin_cost") orderCount := floatFromMap(row, "order_count") orderQty := floatFromMap(row, "order_qty") netStockAfterOrder := floatFromMap(row, "net_stock_after_order") score := minFloat(35, orderUSD/1000) + minFloat(25, maxFloat(0, margin)*60) + minFloat(15, orderCount*2) + minFloat(15, orderQty/10) if netStockAfterOrder >= 0 { score += 10 } return score } func minFloat(a, b float64) float64 { if a < b { return a } return b } func maxFloat(a, b float64) float64 { if a > b { return a } return b } func sanitizeProductPerformanceGroupLevels(levels []string) []string { allowed := map[string]bool{ "kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true, "product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true, "customer_name": true, "country": true, "customer_segment": true, } out := make([]string, 0, len(levels)) seen := map[string]bool{} for _, level := range levels { level = strings.TrimSpace(level) if allowed[level] && !seen[level] { out = append(out, level) seen[level] = true } } return out } func defaultProductPerformanceGroupLevels(mode string) []string { switch mode { case "sales_color_yaka_market_customer": return []string{"color_code", "yaka_kodu", "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"} case "idle": return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} 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_code", "yaka_kodu"} 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_code", "yaka_kodu"} 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_code", "yaka_kodu"} 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_code", "yaka_kodu"} 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_code", "yaka_kodu"} default: return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"} } } func mapGroupValue(row map[string]any, field string) string { switch field { case "urun_ilk_grubu": return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu")) case "market_key": return displayProductPerformanceMarketName(stringFromMap(row, "market_key")) default: return stringFromMap(row, field) } } func shouldSumProductPerformanceField(field string) bool { return strings.HasSuffix(field, "_qty") || strings.HasSuffix(field, "_usd") || strings.HasSuffix(field, "_count") || strings.HasSuffix(field, "_value_usd") || field == "stock_qty" || field == "line_count" || field == "invoice_count" || field == "order_count" || field == "product_count" || field == "market_count" || field == "customer_count" || field == "overdue_qty" || field == "net_stock_after_order" || field == "idle_cost_usd" } func shouldAverageProductPerformanceField(field string) bool { return strings.HasPrefix(field, "avg_") || strings.HasPrefix(field, "unit_") || strings.HasPrefix(field, "base_price") || strings.HasPrefix(field, "cost_price") || strings.HasPrefix(field, "gross_margin") || strings.HasPrefix(field, "expected_margin") || strings.HasPrefix(field, "sales_index") || strings.HasPrefix(field, "performance_score") || strings.HasPrefix(field, "stock_days") || strings.HasPrefix(field, "stock_turnover") } func productPerformanceMetricWeightField(field string) string { if strings.Contains(field, "_180d") { return "sales_qty_180d" } if strings.Contains(field, "_365d") { return "sales_qty_365d" } if strings.Contains(field, "_total") { return "sales_qty_total" } if strings.Contains(field, "order") || strings.Contains(field, "expected_") { return "order_qty" } if strings.Contains(field, "stock") { return "stock_qty" } return "sales_qty_90d" } func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 { var weighted, qty float64 for _, row := range rows { value := floatFromMap(row, valueField) weight := floatFromMap(row, qtyField) if weight > 0 { weighted += value * weight qty += weight } } if qty > 0 { return weighted / qty } return 0 } func dominantProductPerformanceValue(rows []map[string]any, field string) string { counts := map[string]int{} for _, row := range rows { value := stringFromMap(row, field) if field == "urun_ilk_grubu" { value = cleanProductPerformanceFirstGroup(value) } if value != "" { counts[value]++ } } var best string var bestCount int for value, count := range counts { if count > bestCount { best = value bestCount = count } } return best } func firstProductPerformanceImageSource(rows []map[string]any) map[string]any { for _, row := range rows { if stringFromMap(row, "product_code") != "" { return row } } if len(rows) > 0 { return rows[0] } return map[string]any{} } func structsToMaps[T any](rows []T) []map[string]any { raw, err := json.Marshal(rows) if err != nil { return nil } var out []map[string]any if err := json.Unmarshal(raw, &out); err != nil { return nil } return out } func cloneMap(row map[string]any) map[string]any { out := make(map[string]any, len(row)) for key, value := range row { out[key] = value } return out } func normalizeProductPerformanceGroupValue(value string) string { return strings.TrimSpace(value) } func displayProductPerformanceMarketName(value string) string { parts := strings.Split(value, "|") for i := len(parts) - 1; i >= 0; i-- { part := strings.TrimSpace(parts[i]) if part != "" { return part } } return "" } func stringFromMap(row map[string]any, field string) string { value, ok := row[field] if !ok || value == nil { return "" } return strings.TrimSpace(fmt.Sprint(value)) } func cleanProductPerformanceFirstGroup(value string) string { value = strings.TrimSpace(value) if value == "-" { return "" } normalized := strings.ToUpper(value) normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized) switch normalized { case "YETISKIN", "YETISKIN/GARSON", "GARSON": return "" default: return value } } func cleanProductPerformanceOptionalAttr(value string) string { value = strings.TrimSpace(value) if value == "-" { return "" } return value } func floatFromMap(row map[string]any, field string) float64 { return floatFromAny(row[field]) } func floatFromAny(value any) float64 { switch v := value.(type) { case float64: return v case float32: return float64(v) case int: return float64(v) case int64: return float64(v) case json.Number: f, _ := v.Float64() return f default: return 0 } } func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := pg.QueryContext(ctx, ` SELECT to_char(sales_date,'YYYY-MM-DD') AS sales_date, last_ref_number, market_key, customer_country, customer_segment, customer_code, customer_name, sales_qty, sales_usd, avg_price_usd FROM mk_product_performance_sales_daily WHERE product_code=$1 AND color_code=$2 AND yaka_kodu=$3 ORDER BY sales_usd DESC, sales_date DESC LIMIT $4 `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceSalesDetailRow, 0, limit) for rows.Next() { var r models.ProductPerformanceSalesDetailRow if err := rows.Scan(&r.SalesDate, &r.RefNumber, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesUSD, &r.AvgPriceUSD); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCode, yakaKodu string) ([]models.ProductPerformanceStockSizeRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") } rows, err := db.MssqlDB.QueryContext(ctx, ` ;WITH ActiveWarehouses AS ( SELECT WarehouseCode FROM (VALUES ('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'), ('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'), ('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36') ) W(WarehouseCode) ), Stock AS ( SELECT SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))), InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1) FROM trStock S WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) WHERE S.ItemTypeCode = 1 AND LTRIM(RTRIM(S.ItemCode)) = @p1 AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2 AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3 GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) ), Pick AS ( SELECT SizeCode = LTRIM(RTRIM(ISNULL(P.ItemDim1Code, ''))), PickingQty1 = SUM(P.Qty1) FROM PickingStates P WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)) WHERE P.ItemTypeCode = 1 AND LTRIM(RTRIM(P.ItemCode)) = @p1 AND LTRIM(RTRIM(ISNULL(P.ColorCode, ''))) = @p2 AND LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))) = @p3 GROUP BY LTRIM(RTRIM(ISNULL(P.ItemDim1Code, ''))) ), Reserve AS ( SELECT SizeCode = LTRIM(RTRIM(ISNULL(R.ItemDim1Code, ''))), ReserveQty1 = SUM(R.Qty1) FROM ReserveStates R WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)) WHERE R.ItemTypeCode = 1 AND LTRIM(RTRIM(R.ItemCode)) = @p1 AND LTRIM(RTRIM(ISNULL(R.ColorCode, ''))) = @p2 AND LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))) = @p3 GROUP BY LTRIM(RTRIM(ISNULL(R.ItemDim1Code, ''))) ), Disp AS ( SELECT SizeCode = LTRIM(RTRIM(ISNULL(D.ItemDim1Code, ''))), DispOrderQty1 = SUM(D.Qty1) FROM DispOrderStates D WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)) WHERE D.ItemTypeCode = 1 AND LTRIM(RTRIM(D.ItemCode)) = @p1 AND LTRIM(RTRIM(ISNULL(D.ColorCode, ''))) = @p2 AND LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))) = @p3 GROUP BY LTRIM(RTRIM(ISNULL(D.ItemDim1Code, ''))) ) SELECT S.SizeCode, StockQty = CAST(ROUND( ISNULL(S.InventoryQty1, 0) - ISNULL(P.PickingQty1, 0) - ISNULL(R.ReserveQty1, 0) - ISNULL(D.DispOrderQty1, 0), 2 ) AS FLOAT) FROM Stock S LEFT JOIN Pick P ON P.SizeCode = S.SizeCode LEFT JOIN Reserve R ON R.SizeCode = S.SizeCode LEFT JOIN Disp D ON D.SizeCode = S.SizeCode WHERE ( ISNULL(S.InventoryQty1, 0) - ISNULL(P.PickingQty1, 0) - ISNULL(R.ReserveQty1, 0) - ISNULL(D.DispOrderQty1, 0) ) <> 0 ORDER BY S.SizeCode `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu)) if err != nil { return nil, err } defer rows.Close() out := make([]models.ProductPerformanceStockSizeRow, 0, 32) for rows.Next() { var r models.ProductPerformanceStockSizeRow if err := rows.Scan(&r.SizeCode, &r.StockQty); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { parts := make([]string, 0, 6) args := make([]any, 0, 6) add := func(cond string, value any) { args = append(args, value) parts = append(parts, fmt.Sprintf(cond, len(args))) } if q := strings.TrimSpace(f.Search); q != "" { args = append(args, q) idx := len(args) parts = append(parts, fmt.Sprintf(" AND (product_code ILIKE '%%' || $%d || '%%' OR item_description ILIKE '%%' || $%d || '%%')", idx, idx)) } if v := strings.TrimSpace(f.ProductCode); v != "" { add(" AND product_code = $%d", v) } if v := strings.TrimSpace(f.MarketKey); v != "" { add(" AND market_key = $%d", v) } if v := strings.TrimSpace(f.Kategori); v != "" { add(" AND kategori = $%d", v) } if v := strings.TrimSpace(f.Seri); v != "" { args = append(args, v) idx := len(args) parts = append(parts, fmt.Sprintf(" AND (seri = $%d OR urun_ana_grubu = $%d)", idx, idx)) } if v := strings.TrimSpace(f.Bucket); v != "" { add(" AND performance_bucket = $%d", v) } return strings.Join(parts, ""), args } func productPerformanceOrderBy(sortBy string, desc bool) string { allowed := map[string]string{ "product_code": "product_code", "color_code": "color_code", "yaka_kodu": "yaka_kodu", "item_description": "item_description", "kategori": "kategori", "urun_ilk_grubu": "urun_ilk_grubu", "askili_yan": "askili_yan", "urun_ana_grubu": "urun_ana_grubu", "urun_alt_grubu": "urun_alt_grubu", "market_key": "market_key", "stock_qty": "stock_qty", "sales_qty_90d": "sales_qty_90d", "sales_qty_180d": "sales_qty_180d", "sales_usd_90d": "sales_usd_90d", "sales_usd_180d": "sales_usd_180d", "stock_days_90d": "stock_days_90d", "stock_days_180d": "stock_days_180d", "avg_price_usd_90d": "avg_price_usd_90d", "avg_price_usd_180d": "avg_price_usd_180d", "base_price_usd": "base_price_usd", "cost_price_usd": "cost_price_usd", "unit_profit_base_90d": "unit_profit_base_90d", "unit_profit_cost_90d": "unit_profit_cost_90d", "unit_profit_base_180d": "unit_profit_base_180d", "unit_profit_cost_180d": "unit_profit_cost_180d", "gross_margin_base_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_90d,0) END)", "gross_margin_cost_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(cost_price_usd,0))) / NULLIF(sales_usd_90d,0) END)", "gross_margin_base_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_180d,0) END)", "gross_margin_cost_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(cost_price_usd,0))) / NULLIF(sales_usd_180d,0) END)", "gross_margin_90d": "gross_margin_90d", "gross_margin_180d": "gross_margin_180d", "market_count_90d": "market_count_90d", "customer_count_90d": "customer_count_90d", "sales_index_90d": "sales_index_90d", "performance_score": "performance_score", "performance_bucket": "performance_bucket", "last_sale_date": "last_sale_date", } col := allowed[strings.TrimSpace(sortBy)] if col == "" { col = "performance_score" desc = true } dir := "ASC" if desc { dir = "DESC" } return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC" } type productPerformancePriceInfo struct { cost float64 base float64 } type productPerformanceAttrInfo struct { itemDescription string kategori string askiliYan string urunIlkGrubu string urunAnaGrubu string urunAltGrubu string } func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) string { return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu) } func productPerformancePriceLookup(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]productPerformancePriceInfo, error) { out := make(map[string]productPerformancePriceInfo, len(productCodes)) if len(productCodes) == 0 { return out, nil } rows, err := pg.QueryContext(ctx, ` SELECT product_code, COALESCE(cost_price_usd,0), COALESCE(base_price_usd,0) FROM mk_product_performance_price_dim WHERE product_code = ANY($1) `, pq.Array(productCodes)) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var productCode string var p productPerformancePriceInfo if err := rows.Scan(&productCode, &p.cost, &p.base); err != nil { return nil, err } out[productCode] = p } return out, rows.Err() } func productPerformanceAttrLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]productPerformanceAttrInfo, error) { out := make(map[string]productPerformanceAttrInfo, len(variantKeys)) if len(variantKeys) == 0 { return out, nil } rows, err := pg.QueryContext(ctx, ` WITH Latest AS ( SELECT MAX(kpi_date) AS kpi_date FROM mk_product_performance_kpi_daily ) SELECT DISTINCT ON (product_code, color_code, yaka_kodu) product_code, color_code, yaka_kodu, COALESCE(item_description,'') AS item_description, COALESCE(kategori,'') AS kategori, CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan, COALESCE(urun_ilk_grubu,'') AS urun_ilk_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, COALESCE(urun_alt_grubu,'') AS urun_alt_grubu FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) ORDER BY product_code, color_code, yaka_kodu, performance_score DESC `, pq.Array(variantKeys)) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var productCode, colorCode, yakaKodu string var p productPerformanceAttrInfo if err := rows.Scan(&productCode, &colorCode, &yakaKodu, &p.itemDescription, &p.kategori, &p.askiliYan, &p.urunIlkGrubu, &p.urunAnaGrubu, &p.urunAltGrubu); err != nil { return nil, err } p.askiliYan = cleanProductPerformanceOptionalAttr(p.askiliYan) p.urunIlkGrubu = cleanProductPerformanceFirstGroup(p.urunIlkGrubu) out[productPerformanceVariantKey(productCode, colorCode, yakaKodu)] = p } return out, rows.Err() } func productPerformanceStockLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]float64, error) { out := make(map[string]float64, len(variantKeys)) if len(variantKeys) == 0 { return out, nil } rows, err := pg.QueryContext(ctx, ` WITH Latest AS ( SELECT MAX(stock_date) AS stock_date FROM mk_product_performance_stock_daily ) SELECT product_code || '|' || color_code || '|' || yaka_kodu AS variant_key, COALESCE(SUM(stock_qty),0) AS stock_qty FROM mk_product_performance_stock_daily WHERE stock_date = (SELECT stock_date FROM Latest) AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) GROUP BY product_code, color_code, yaka_kodu `, pq.Array(variantKeys)) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var key string var stockQty float64 if err := rows.Scan(&key, &stockQty); err != nil { return nil, err } out[key] = stockQty } return out, rows.Err() } 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." case row.ExpectedProfitCostUSD < 0: return "FIYAT_BASKISI", "Açık sipariş çıplak maliyete göre zarar yazıyor. 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ı." case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0: return "FIYAT_FIRSATI", "Karlı talep var ama stok yetersiz. Üretim planına alınmalı." case row.OverdueQty > 0: return "TAKIP", "Termin gecikmesi olan açık sipariş var. Operasyon takibi gerekli." default: return "TAKIP", "Sipariş, stok ve fiyat düzenli 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." case row.ExpectedProfitBaseUSD < 0: return "FIYAT_BASKISI", "Taban maliyete göre brüt zarar var. Satış fiyatı 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." case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15: return "YILDIZ_URUN", "Piyasa/müşteri açık siparişleri karlı. Teslimat ve stok korunmalı." case row.OverdueQty > 0: return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli." default: return "TAKIP", "Sipariş karlılığı ve stok yeterliliği 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." 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." case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: return "STOKSUZ_TALEP", "Müşteride karlı 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." case row.OverdueQty > 0: return "TAKIP", "Bu müşteri/ürün kırılımında geciken açık sipariş var." default: return "TAKIP", "Müşteri 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." case row.ExpectedProfitBaseUSD < 0: return "FIYAT_BASKISI", "Sipariş satırı taban maliyete göre brüt zarar yazıyor." case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: return "STOKSUZ_TALEP", "Karlı sipariş var ama mevcut stok siparişi karşılamıyor." case row.IsOverdue: return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli." case row.ExpectedMarginCost >= 0.25: return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı." default: return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli." } } func dateOnly(t time.Time) time.Time { y, m, d := t.Date() return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) } type productPerformanceSalesDaily struct { SalesDate time.Time ProductCode string ColorCode string YakaKodu string ItemDescription string Kategori string Seri string YasGrubu string AskiliYan string UrunIlkGrubu string UrunAnaGrubu string UrunAltGrubu string MarketKey string ChannelCode string CustomerCountry string CustomerSegment string CustomerCode string CustomerName string SalesQty float64 SalesTL float64 SalesUSD float64 AvgPriceUSD float64 InvoiceLineCount int InvoiceCount int CustomerCount int LastRefNumber string } type productPerformanceStockDaily struct { StockDate time.Time ProductCode string ColorCode string YakaKodu string StockQty float64 InQty float64 OutQty float64 KpiInQty float64 KpiOutQty float64 SalesMovementQty float64 ProductionInQty float64 PurchaseInQty float64 ConsumptionOutQty float64 CountDiffQty float64 }