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 @@
+
+
+
+
+
+
+
+
+ {{ bucketLabel(props.row.performance_bucket) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ bucketLabel(props.row.performance_bucket) }}
+
+
+
+
+
+ {{ formatNumber(props.row.net_stock_after_order, 0) }}
+
+
+
+
+ {{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
+
+
+
+
+
+
+
+
+ {{ bucketLabel(props.row.performance_bucket) }}
+
+
+
+
+
+ {{ formatNumber(props.row.net_stock_after_order, 0) }}
+
+
+
+
+ {{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
+
+
+
+
+
-
@@ -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 @@