diff --git a/svc/cmd/product_performance_refresh/main.go b/svc/cmd/product_performance_refresh/main.go index 6a3b658..3f4829e 100644 --- a/svc/cmd/product_performance_refresh/main.go +++ b/svc/cmd/product_performance_refresh/main.go @@ -21,6 +21,7 @@ func main() { stage := flag.String("stage", "all", "refresh stage: all, sales, stock, price, or kpi") resumeAfter := flag.Int("resume-after", 0, "skip this many source rows before inserting; implies skip-delete") skipDelete := flag.Bool("skip-delete", false, "skip deleting cache rows for the selected stage") + productPrefix := flag.String("product-prefix", "", "optional product code prefix filter: S, O, N, X, I, or A") start := flag.String("start", "2022-01-01", "start date in YYYY-MM-DD") end := flag.String("end", "", "end date in YYYY-MM-DD; default today") mssqlTimeoutSec := flag.Int("mssql-timeout-sec", 43200, "MSSQL connection/read timeout in seconds for long refresh jobs") @@ -66,12 +67,13 @@ func main() { defer cancel() result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ - Mode: *mode, - Stage: *stage, - ResumeAfter: *resumeAfter, - SkipDelete: *skipDelete, - StartDate: startDate, - EndDate: endDate, + Mode: *mode, + Stage: *stage, + ResumeAfter: *resumeAfter, + SkipDelete: *skipDelete, + ProductPrefix: *productPrefix, + StartDate: startDate, + EndDate: endDate, }) if err != nil { log.Fatalf("refresh failed: %v", err) diff --git a/svc/main.go b/svc/main.go index c9a6586..c5a3049 100644 --- a/svc/main.go +++ b/svc/main.go @@ -939,6 +939,31 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(routes.GetProductPerformanceSummaryHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/pricing/product-performance/general", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceGeneralHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/orders", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceOrdersHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/orders/groups", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceOrderGroupsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/orders/product-customers", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceOrderProductCustomersHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/product-performance/orders/market-details", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceOrderMarketDetailsHandler(pgDB)), + ) bindV3(r, pgDB, "/api/pricing/product-performance/markets", "GET", "pricing", "view", diff --git a/svc/models/product_performance.go b/svc/models/product_performance.go index bb48273..c56289a 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -71,6 +71,162 @@ type ProductPerformanceSummary struct { UpdatedAt string `json:"updated_at"` } +type ProductPerformanceGeneralRow struct { + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + Kategori string `json:"kategori"` + Seri string `json:"seri"` + YasGrubu string `json:"yas_grubu"` + AskiliYan string `json:"askili_yan"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + MarketKey string `json:"market_key"` + StockQty float64 `json:"stock_qty"` + SalesQtyTotal float64 `json:"sales_qty_total"` + SalesUSDTotal float64 `json:"sales_usd_total"` + AvgDailySalesTotal float64 `json:"avg_daily_sales_total"` + StockDaysTotal float64 `json:"stock_days_total"` + AvgPriceUSDTotal float64 `json:"avg_price_usd_total"` + CostPriceUSD float64 `json:"cost_price_usd"` + BasePriceUSD float64 `json:"base_price_usd"` + GrossProfitUSDTotal float64 `json:"gross_profit_usd_total"` + GrossMarginTotal float64 `json:"gross_margin_total"` + UnitProfitCostTotal float64 `json:"unit_profit_cost_total"` + UnitProfitBaseTotal float64 `json:"unit_profit_base_total"` + MarketCountTotal int `json:"market_count_total"` + CustomerCountTotal int `json:"customer_count_total"` + InvoiceCountTotal int `json:"invoice_count_total"` + SalesIndexTotal float64 `json:"sales_index_total"` + PerformanceScore float64 `json:"performance_score"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` + FirstSaleDate string `json:"first_sale_date"` + LastSaleDate string `json:"last_sale_date"` + LastRefNumber string `json:"last_ref_number"` +} + +type ProductPerformanceOrderAnalysisRow struct { + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + Kategori string `json:"kategori"` + Seri string `json:"seri"` + YasGrubu string `json:"yas_grubu"` + AskiliYan string `json:"askili_yan"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + MarketKey string `json:"market_key"` + OrderQty float64 `json:"order_qty"` + OrderUSD float64 `json:"order_usd"` + AvgOrderPriceUSD float64 `json:"avg_order_price_usd"` + StockQty float64 `json:"stock_qty"` + NetStockAfterOrder float64 `json:"net_stock_after_order"` + CostPriceUSD float64 `json:"cost_price_usd"` + BasePriceUSD float64 `json:"base_price_usd"` + UnitProfitCostUSD float64 `json:"unit_profit_cost_usd"` + UnitProfitBaseUSD float64 `json:"unit_profit_base_usd"` + ExpectedProfitCostUSD float64 `json:"expected_profit_cost_usd"` + ExpectedProfitBaseUSD float64 `json:"expected_profit_base_usd"` + ExpectedMarginCost float64 `json:"expected_margin_cost"` + MarketCount int `json:"market_count"` + CustomerCount int `json:"customer_count"` + OrderCount int `json:"order_count"` + LineCount int `json:"line_count"` + FirstOrderDate string `json:"first_order_date"` + LastOrderDate string `json:"last_order_date"` + EarliestDueDate string `json:"earliest_due_date"` + OverdueQty float64 `json:"overdue_qty"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` +} + +type ProductPerformanceOrderGroupRow struct { + Breakdown string `json:"breakdown"` + GroupKey string `json:"group_key"` + MarketKey string `json:"market_key"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + ProductCount int `json:"product_count"` + OrderQty float64 `json:"order_qty"` + OrderUSD float64 `json:"order_usd"` + AvgOrderPriceUSD float64 `json:"avg_order_price_usd"` + BaseCostUSD float64 `json:"base_cost_usd"` + CostAmountUSD float64 `json:"cost_amount_usd"` + ExpectedProfitBaseUSD float64 `json:"expected_profit_base_usd"` + ExpectedProfitCostUSD float64 `json:"expected_profit_cost_usd"` + ExpectedMarginBase float64 `json:"expected_margin_base"` + ExpectedMarginCost float64 `json:"expected_margin_cost"` + StockQty float64 `json:"stock_qty"` + NetStockAfterOrder float64 `json:"net_stock_after_order"` + MarketCount int `json:"market_count"` + CustomerCount int `json:"customer_count"` + OrderCount int `json:"order_count"` + LineCount int `json:"line_count"` + OverdueQty float64 `json:"overdue_qty"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` +} + +type ProductPerformanceOrderProductCustomerRow struct { + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + MarketKey string `json:"market_key"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + OrderQty float64 `json:"order_qty"` + OrderUSD float64 `json:"order_usd"` + AvgOrderPriceUSD float64 `json:"avg_order_price_usd"` + StockQty float64 `json:"stock_qty"` + NetStockAfterOrder float64 `json:"net_stock_after_order"` + CostPriceUSD float64 `json:"cost_price_usd"` + BasePriceUSD float64 `json:"base_price_usd"` + ExpectedProfitBaseUSD float64 `json:"expected_profit_base_usd"` + ExpectedProfitCostUSD float64 `json:"expected_profit_cost_usd"` + ExpectedMarginBase float64 `json:"expected_margin_base"` + ExpectedMarginCost float64 `json:"expected_margin_cost"` + OrderCount int `json:"order_count"` + LineCount int `json:"line_count"` + OverdueQty float64 `json:"overdue_qty"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` +} + +type ProductPerformanceOrderMarketDetailRow struct { + MarketKey string `json:"market_key"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + OrderNumber string `json:"order_number"` + OrderDate string `json:"order_date"` + DueDate string `json:"due_date"` + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + OrderQty float64 `json:"order_qty"` + OrderUSD float64 `json:"order_usd"` + AvgOrderPriceUSD float64 `json:"avg_order_price_usd"` + StockQty float64 `json:"stock_qty"` + NetStockAfterOrder float64 `json:"net_stock_after_order"` + CostPriceUSD float64 `json:"cost_price_usd"` + BasePriceUSD float64 `json:"base_price_usd"` + ExpectedProfitBaseUSD float64 `json:"expected_profit_base_usd"` + ExpectedProfitCostUSD float64 `json:"expected_profit_cost_usd"` + ExpectedMarginBase float64 `json:"expected_margin_base"` + ExpectedMarginCost float64 `json:"expected_margin_cost"` + IsOverdue bool `json:"is_overdue"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` +} + type ProductPerformanceMarketRow struct { MarketKey string `json:"market_key"` Kategori string `json:"kategori"` diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 7854cc6..0675120 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -7,8 +7,11 @@ import ( "database/sql" "fmt" "log" + "sort" "strings" "time" + + "github.com/lib/pq" ) type ProductPerformanceFilters struct { @@ -25,12 +28,13 @@ type ProductPerformanceFilters struct { } type ProductPerformanceRefreshRequest struct { - Mode string - Stage string - ResumeAfter int - SkipDelete bool - StartDate time.Time - EndDate time.Time + Mode string + Stage string + ResumeAfter int + SkipDelete bool + ProductPrefix string + StartDate time.Time + EndDate time.Time } type ProductPerformanceRefreshResult struct { @@ -219,7 +223,8 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) { started := time.Now() stage := productPerformanceRefreshStage(req.Stage) - log.Printf("[ProductPerformanceRefresh] start mode=%s stage=%s start=%s end=%s", req.Mode, stage, req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02")) + 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") } @@ -282,12 +287,18 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo if shouldRun("sales") { if err := runStage("sales", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] delete sales cache start") - if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN $1 AND $2`, req.StartDate, req.EndDate); err != nil { + 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) + rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate, productPrefix) if err != nil { return err } @@ -303,7 +314,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo var stockRows int if shouldRun("stock") { - rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter) + rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter, productPrefix) if err != nil { return ProductPerformanceRefreshResult{}, err } @@ -315,7 +326,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo if shouldRun("price") { if err := runStage("price", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] price refresh start") - if err := refreshProductPerformancePrices(ctx, tx); err != nil { + if err := refreshProductPerformancePrices(ctx, tx, productPrefix); err != nil { return err } log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second)) @@ -369,9 +380,19 @@ func productPerformanceRefreshStage(raw string) string { } } -func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) { - log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) - rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate) +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 } @@ -432,9 +453,9 @@ DO UPDATE SET return count, rows.Err() } -func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) { - log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) - rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate) +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, productPerformanceStockSQL(), startDate, endDate, productPrefix) if err != nil { return 0, err } @@ -461,7 +482,7 @@ func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, return count, rows.Err() } -func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, startDate, endDate, started time.Time, skipDelete bool, resumeAfter int) (int, error) { +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 { @@ -472,7 +493,13 @@ func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, star } defer deleteTx.Rollback() log.Printf("[ProductPerformanceRefresh] delete stock cache start") - if _, err := deleteTx.ExecContext(ctx, `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2`, startDate, endDate); err != nil { + 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") @@ -484,8 +511,8 @@ func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, star } log.Printf("[ProductPerformanceRefresh] stock refresh start") - log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) - rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate) + 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, productPerformanceStockSQL(), startDate, endDate, productPrefix) if err != nil { return 0, err } @@ -582,9 +609,9 @@ DO UPDATE SET return err } -func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx) error { - log.Printf("[ProductPerformanceRefresh] price mssql query start") - rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL()) +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 } @@ -742,6 +769,965 @@ CROSS JOIN Sales90 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 > 1000 { + limit = 500 + } + 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, + MAX(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 +), +StockOnly AS ( + SELECT + s.product_code, + s.color_code, + s.yaka_kodu, + 'STOK'::text AS market_key, + MAX(s.item_description) AS item_description, + MAX(s.kategori) AS kategori, + MAX(s.seri) AS seri, + MAX(s.yas_grubu) AS yas_grubu, + MAX(s.askili_yan) AS askili_yan, + MAX(s.urun_ilk_grubu) AS urun_ilk_grubu, + MAX(s.urun_ana_grubu) AS urun_ana_grubu, + MAX(s.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 + 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 + } + 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 > 1000 { + limit = 500 + } + + 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 = 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))), + 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 = 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))), + 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 = MAX(o.AskiliYan), + 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.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.UrunIlkGrubu = r.YasGrubu + 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.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 + } + 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 = 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 > 1500 { + limit = 800 + } + 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 @@ -1076,6 +2062,141 @@ func productPerformanceOrderBy(sortBy string, desc bool) string { return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC" } +type productPerformancePriceInfo struct { + cost float64 + base float64 +} + +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 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()) diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index f25a0ec..e5466d4 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -153,7 +153,18 @@ func productPerformanceSalesSQL() string { WHERE I.InvoiceDate >= @p1 AND I.InvoiceDate < DATEADD(DAY, 1, @p2) AND I.ItemTypeCode = 1 - AND (I.ItemCode LIKE 'S%' OR I.ItemCode LIKE 'O%' OR I.ItemCode LIKE 'X%' OR I.ItemCode LIKE 'N%') + AND ( + LEN(LTRIM(RTRIM(I.ItemCode))) = 13 + AND ( + I.ItemCode LIKE 'S%' + OR I.ItemCode LIKE 'O%' + OR I.ItemCode LIKE 'N%' + OR I.ItemCode LIKE 'X%' + OR I.ItemCode LIKE 'I%' + OR I.ItemCode LIKE 'A%' + ) + ) + AND (@p3 = '' OR I.ItemCode LIKE @p3 + '%') AND ( (I.CompanyCode = 1 AND I.ATAtt01 IN (1,2) AND I.ProcessCode IN ('WS','R')) OR (I.CompanyCode = 4 AND I.ProcessCode = 'R') @@ -228,7 +239,18 @@ Raw AS ( INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) WHERE S.ItemTypeCode = 1 - AND (S.ItemCode LIKE 'S%' OR S.ItemCode LIKE 'O%' OR S.ItemCode LIKE 'X%' OR S.ItemCode LIKE 'N%') + 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, @@ -334,12 +356,34 @@ func productPerformancePriceSQL() string { SELECT DISTINCT ItemCode FROM trPriceListLine WITH(NOLOCK) WHERE ItemTypeCode = 1 - AND (ItemCode LIKE 'S%' OR ItemCode LIKE 'O%' OR ItemCode LIKE 'X%' OR ItemCode LIKE 'N%') + AND ( + LEN(LTRIM(RTRIM(ItemCode))) = 13 + AND ( + ItemCode LIKE 'S%' + OR ItemCode LIKE 'O%' + OR ItemCode LIKE 'N%' + OR ItemCode LIKE 'X%' + OR ItemCode LIKE 'I%' + OR ItemCode LIKE 'A%' + ) + ) + AND (@p1 = '' OR ItemCode LIKE @p1 + '%') UNION SELECT DISTINCT ItemCode FROM prItemBasePrice WITH(NOLOCK) WHERE ItemTypeCode = 1 - AND (ItemCode LIKE 'S%' OR ItemCode LIKE 'O%' OR ItemCode LIKE 'X%' OR ItemCode LIKE 'N%') + AND ( + LEN(LTRIM(RTRIM(ItemCode))) = 13 + AND ( + ItemCode LIKE 'S%' + OR ItemCode LIKE 'O%' + OR ItemCode LIKE 'N%' + OR ItemCode LIKE 'X%' + OR ItemCode LIKE 'I%' + OR ItemCode LIKE 'A%' + ) + ) + AND (@p1 = '' OR ItemCode LIKE @p1 + '%') ), LatestPrice AS ( SELECT diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 4ccec18..36afee0 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -102,6 +102,91 @@ func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc { } } +func GetProductPerformanceGeneralHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 500)) + if err != nil { + http.Error(w, "genel urun performans KPI alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceOrdersHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 500)) + if err != nil { + http.Error(w, "siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceOrderGroupsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceOrderGroups( + ctx, + pg, + strings.TrimSpace(r.URL.Query().Get("breakdown")), + intQuery(r, "limit", 500), + ) + if err != nil { + http.Error(w, "siparis grup analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 500)) + if err != nil { + http.Error(w, "urun musteri siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func GetProductPerformanceOrderMarketDetailsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 800)) + if err != nil { + http.Error(w, "piyasa siparis detay analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -156,12 +241,13 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc { } type productPerformanceRefreshPayload struct { - Mode string `json:"mode"` - Stage string `json:"stage"` - ResumeAfter int `json:"resume_after"` - SkipDelete bool `json:"skip_delete"` - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` + Mode string `json:"mode"` + Stage string `json:"stage"` + ResumeAfter int `json:"resume_after"` + SkipDelete bool `json:"skip_delete"` + ProductPrefix string `json:"product_prefix"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` } func PostProductPerformanceRefreshHandler(pg *sql.DB) http.HandlerFunc { @@ -181,12 +267,13 @@ func PostProductPerformanceRefreshHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(r.Context(), 4*time.Hour) defer cancel() result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ - Mode: payload.Mode, - Stage: payload.Stage, - ResumeAfter: payload.ResumeAfter, - SkipDelete: payload.SkipDelete, - StartDate: startDate, - EndDate: endDate, + Mode: payload.Mode, + Stage: payload.Stage, + ResumeAfter: payload.ResumeAfter, + SkipDelete: payload.SkipDelete, + ProductPrefix: payload.ProductPrefix, + StartDate: startDate, + EndDate: endDate, }) if err != nil { http.Error(w, "urun performans refresh hatasi: "+err.Error(), http.StatusInternalServerError) diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 7d334b3..3954322 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -356,7 +356,7 @@ const menuItems = [ }, { - label: 'Ürün Fiyatlandırma', + label: 'Ürün Fiyatlandırma ve Analiz', icon: 'sell', children: [ diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 6cd2d83..051f06b 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -32,38 +32,6 @@ - - -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- + + + @@ -80,7 +51,121 @@ + + + + + + + + + + + + + + + + + + - @@ -135,7 +227,10 @@ Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} · Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} · Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} · - Marj {{ formatPercent(props.row.avg_gross_margin_90d) }} + 90G K/Z {{ formatMoney(props.row.gross_profit_usd_90d, 'USD') }} · + 90G Marj {{ formatPercent(props.row.gross_margin_90d) }} · + 180G K/Z {{ formatMoney(props.row.gross_profit_usd_180d, 'USD') }} · + 180G Marj {{ formatPercent(props.row.gross_margin_180d) }} @@ -400,7 +495,7 @@