ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-02 17:39:22 +03:00
parent 1bf51b9e82
commit d98ce372c7
8 changed files with 1982 additions and 105 deletions
+8 -6
View File
@@ -21,6 +21,7 @@ func main() {
stage := flag.String("stage", "all", "refresh stage: all, sales, stock, price, or kpi") 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") 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") 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") 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") 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") 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() defer cancel()
result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{
Mode: *mode, Mode: *mode,
Stage: *stage, Stage: *stage,
ResumeAfter: *resumeAfter, ResumeAfter: *resumeAfter,
SkipDelete: *skipDelete, SkipDelete: *skipDelete,
StartDate: startDate, ProductPrefix: *productPrefix,
EndDate: endDate, StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
log.Fatalf("refresh failed: %v", err) log.Fatalf("refresh failed: %v", err)
+25
View File
@@ -939,6 +939,31 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "view", "pricing", "view",
wrapV3(routes.GetProductPerformanceSummaryHandler(pgDB)), 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, bindV3(r, pgDB,
"/api/pricing/product-performance/markets", "GET", "/api/pricing/product-performance/markets", "GET",
"pricing", "view", "pricing", "view",
+156
View File
@@ -71,6 +71,162 @@ type ProductPerformanceSummary struct {
UpdatedAt string `json:"updated_at"` 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 { type ProductPerformanceMarketRow struct {
MarketKey string `json:"market_key"` MarketKey string `json:"market_key"`
Kategori string `json:"kategori"` Kategori string `json:"kategori"`
+1145 -24
View File
@@ -7,8 +7,11 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log" "log"
"sort"
"strings" "strings"
"time" "time"
"github.com/lib/pq"
) )
type ProductPerformanceFilters struct { type ProductPerformanceFilters struct {
@@ -25,12 +28,13 @@ type ProductPerformanceFilters struct {
} }
type ProductPerformanceRefreshRequest struct { type ProductPerformanceRefreshRequest struct {
Mode string Mode string
Stage string Stage string
ResumeAfter int ResumeAfter int
SkipDelete bool SkipDelete bool
StartDate time.Time ProductPrefix string
EndDate time.Time StartDate time.Time
EndDate time.Time
} }
type ProductPerformanceRefreshResult struct { 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) { func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
started := time.Now() started := time.Now()
stage := productPerformanceRefreshStage(req.Stage) 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 { if pg == nil {
return ProductPerformanceRefreshResult{}, fmt.Errorf("postgres db 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 shouldRun("sales") {
if err := runStage("sales", func(tx *sql.Tx) error { if err := runStage("sales", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] delete sales cache start") 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 return err
} }
log.Printf("[ProductPerformanceRefresh] delete sales cache done") log.Printf("[ProductPerformanceRefresh] delete sales cache done")
log.Printf("[ProductPerformanceRefresh] sales refresh start") 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 { if err != nil {
return err return err
} }
@@ -303,7 +314,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
var stockRows int var stockRows int
if shouldRun("stock") { 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 { if err != nil {
return ProductPerformanceRefreshResult{}, err return ProductPerformanceRefreshResult{}, err
} }
@@ -315,7 +326,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo
if shouldRun("price") { if shouldRun("price") {
if err := runStage("price", func(tx *sql.Tx) error { if err := runStage("price", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] price refresh start") log.Printf("[ProductPerformanceRefresh] price refresh start")
if err := refreshProductPerformancePrices(ctx, tx); err != nil { if err := refreshProductPerformancePrices(ctx, tx, productPrefix); err != nil {
return err return err
} }
log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second)) 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) { func productPerformanceProductPrefix(raw string) string {
log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) prefix := strings.ToUpper(strings.TrimSpace(raw))
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate) 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 { if err != nil {
return 0, err return 0, err
} }
@@ -432,9 +453,9 @@ DO UPDATE SET
return count, rows.Err() return count, rows.Err()
} }
func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) { 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", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) 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) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate, productPrefix)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -461,7 +482,7 @@ func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate,
return count, rows.Err() 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 { if skipDelete {
log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter) log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter)
} else { } else {
@@ -472,7 +493,13 @@ func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, star
} }
defer deleteTx.Rollback() defer deleteTx.Rollback()
log.Printf("[ProductPerformanceRefresh] delete stock cache start") 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 return 0, err
} }
log.Printf("[ProductPerformanceRefresh] delete stock cache done") 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 refresh start")
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) 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) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate, productPrefix)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -582,9 +609,9 @@ DO UPDATE SET
return err return err
} }
func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx) error { func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx, productPrefix string) error {
log.Printf("[ProductPerformanceRefresh] price mssql query start") log.Printf("[ProductPerformanceRefresh] price mssql query start prefix=%s", productPrefix)
rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL()) rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL(), productPrefix)
if err != nil { if err != nil {
return err return err
} }
@@ -742,6 +769,965 @@ CROSS JOIN Sales90
return s, err 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) { func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) {
if err := EnsureProductPerformanceTables(pg); err != nil { if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err 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" 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 { func dateOnly(t time.Time) time.Time {
y, m, d := t.Date() y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
+48 -4
View File
@@ -153,7 +153,18 @@ func productPerformanceSalesSQL() string {
WHERE I.InvoiceDate >= @p1 WHERE I.InvoiceDate >= @p1
AND I.InvoiceDate < DATEADD(DAY, 1, @p2) AND I.InvoiceDate < DATEADD(DAY, 1, @p2)
AND I.ItemTypeCode = 1 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 ( AND (
(I.CompanyCode = 1 AND I.ATAtt01 IN (1,2) AND I.ProcessCode IN ('WS','R')) (I.CompanyCode = 1 AND I.ATAtt01 IN (1,2) AND I.ProcessCode IN ('WS','R'))
OR (I.CompanyCode = 4 AND I.ProcessCode = 'R') OR (I.CompanyCode = 4 AND I.ProcessCode = 'R')
@@ -228,7 +239,18 @@ Raw AS (
INNER JOIN ActiveWarehouses W INNER JOIN ActiveWarehouses W
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
WHERE S.ItemTypeCode = 1 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) AND S.DocumentDate < DATEADD(DAY, 1, @p2)
GROUP BY GROUP BY
S.ItemCode, S.ItemCode,
@@ -334,12 +356,34 @@ func productPerformancePriceSQL() string {
SELECT DISTINCT ItemCode SELECT DISTINCT ItemCode
FROM trPriceListLine WITH(NOLOCK) FROM trPriceListLine WITH(NOLOCK)
WHERE ItemTypeCode = 1 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 UNION
SELECT DISTINCT ItemCode SELECT DISTINCT ItemCode
FROM prItemBasePrice WITH(NOLOCK) FROM prItemBasePrice WITH(NOLOCK)
WHERE ItemTypeCode = 1 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 ( LatestPrice AS (
SELECT SELECT
+99 -12
View File
@@ -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 { func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -156,12 +241,13 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc {
} }
type productPerformanceRefreshPayload struct { type productPerformanceRefreshPayload struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Stage string `json:"stage"` Stage string `json:"stage"`
ResumeAfter int `json:"resume_after"` ResumeAfter int `json:"resume_after"`
SkipDelete bool `json:"skip_delete"` SkipDelete bool `json:"skip_delete"`
StartDate string `json:"start_date"` ProductPrefix string `json:"product_prefix"`
EndDate string `json:"end_date"` StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
} }
func PostProductPerformanceRefreshHandler(pg *sql.DB) http.HandlerFunc { 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) ctx, cancel := context.WithTimeout(r.Context(), 4*time.Hour)
defer cancel() defer cancel()
result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{ result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{
Mode: payload.Mode, Mode: payload.Mode,
Stage: payload.Stage, Stage: payload.Stage,
ResumeAfter: payload.ResumeAfter, ResumeAfter: payload.ResumeAfter,
SkipDelete: payload.SkipDelete, SkipDelete: payload.SkipDelete,
StartDate: startDate, ProductPrefix: payload.ProductPrefix,
EndDate: endDate, StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
http.Error(w, "urun performans refresh hatasi: "+err.Error(), http.StatusInternalServerError) http.Error(w, "urun performans refresh hatasi: "+err.Error(), http.StatusInternalServerError)
+1 -1
View File
@@ -356,7 +356,7 @@ const menuItems = [
}, },
{ {
label: 'Ürün Fiyatlandırma', label: 'Ürün Fiyatlandırma ve Analiz',
icon: 'sell', icon: 'sell',
children: [ children: [
+500 -58
View File
@@ -32,38 +32,6 @@
</div> </div>
</div> </div>
<q-card flat bordered class="q-mb-sm">
<q-card-section class="row q-col-gutter-sm items-end">
<div class="col-12 col-md-3">
<q-input v-model="filters.q" dense outlined clearable label="Ürün / açıklama ara" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.product_code" dense outlined clearable label="Ürün kodu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.kategori" dense outlined clearable label="Kategori" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.seri" dense outlined clearable label="Ürün Ana Grubu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-select
v-model="filters.bucket"
dense
outlined
clearable
emit-value
map-options
label="Durum"
:options="bucketOptions"
/>
</div>
<div class="col-12 col-md-1">
<q-btn class="full-width" color="secondary" outline icon="search" label="Ara" @click="reload" />
</div>
</q-card-section>
</q-card>
<q-tabs <q-tabs
v-model="activeTab" v-model="activeTab"
dense dense
@@ -73,6 +41,9 @@
align="left" align="left"
> >
<q-tab name="products" icon="inventory_2" label="Ürün KPI" /> <q-tab name="products" icon="inventory_2" label="Ürün KPI" />
<q-tab name="general" icon="analytics" label="KPI Genel" />
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Müşteri Sipariş" />
<q-tab name="order_market_details" icon="account_tree" label="Piyasa > Sipariş Detay" />
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" /> <q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
<q-tab name="markets" icon="query_stats" label="Piyasa Grup Kıyası" /> <q-tab name="markets" icon="query_stats" label="Piyasa Grup Kıyası" />
<q-tab name="countries" icon="public" label="Ülke / Segment" /> <q-tab name="countries" icon="public" label="Ülke / Segment" />
@@ -80,7 +51,121 @@
</q-tabs> </q-tabs>
<q-table <q-table
v-if="activeTab === 'products'" v-if="activeTab === 'general'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="generalRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_product_customers'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderProductCustomerRows"
:columns="orderProductCustomerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_market_details'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderMarketDetailRows"
:columns="orderMarketDetailColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'products'"
flat flat
bordered bordered
row-key="row_key" row-key="row_key"
@@ -99,15 +184,22 @@
</q-tr> </q-tr>
<q-tr class="table-filter-row"> <q-tr class="table-filter-row">
<q-th v-for="col in props.cols" :key="`filter-${col.name}`"> <q-th v-for="col in props.cols" :key="`filter-${col.name}`">
<q-input <q-select
v-if="isColumnFilterable(col.name)" v-if="isColumnFilterable(col.name)"
v-model="columnFilters[col.name]" v-model="columnFilters[col.name]"
:options="columnFilterOptions(col.name)"
dense dense
borderless borderless
clearable clearable
debounce="250" multiple
use-chips
emit-value
map-options
options-dense
class="header-filter" class="header-filter"
placeholder="Filtre" popup-content-class="header-filter-popup"
placeholder="Seç"
@click.stop
/> />
</q-th> </q-th>
</q-tr> </q-tr>
@@ -135,7 +227,10 @@
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} · Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} · Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
Çıplak {{ formatMoney(props.row.avg_cost_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) }}
</span> </span>
</q-td> </q-td>
</q-tr> </q-tr>
@@ -400,7 +495,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import api from 'src/services/api' import api from 'src/services/api'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters' import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
@@ -412,6 +507,16 @@ const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const rows = ref([]) const rows = ref([])
const generalRows = ref([])
const orderAnalysisRows = ref([])
const orderMarketRows = ref([])
const orderCustomerRows = ref([])
const orderProductCustomerRows = ref([])
const orderMarketDetailRows = ref([])
const orderAnalysisLoaded = ref(false)
const orderProductCustomersLoaded = ref(false)
const orderMarketDetailsLoaded = ref(false)
const orderAnalysisMode = ref('product')
const marketRows = ref([]) const marketRows = ref([])
const countryRows = ref([]) const countryRows = ref([])
const customerRows = ref([]) const customerRows = ref([])
@@ -433,13 +538,6 @@ const detailLoading = ref(false)
const detailSalesRows = ref([]) const detailSalesRows = ref([])
const detailStockSizes = ref([]) const detailStockSizes = ref([])
const filters = reactive({
q: '',
product_code: '',
kategori: '',
seri: '',
bucket: ''
})
const columnFilters = reactive({}) const columnFilters = reactive({})
const expandedGroups = ref({}) const expandedGroups = ref({})
@@ -510,6 +608,8 @@ const columns = [
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true }, { name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true }, { name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true }, { name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
@@ -520,6 +620,155 @@ const columns = [
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
] ]
const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true },
{ name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Toplam Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_total', label: 'Genel Marj', field: row => formatPercent(row.gross_margin_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_usd', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_usd', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Taban Toplam K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Çıplak Toplam K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Beklenen Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'earliest_due_date', label: 'İlk Termin', field: 'earliest_due_date', align: 'left', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderGroupColumns = [
{ name: 'group_key', label: 'Kırılım', field: row => orderGroupLabel(row), align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderProductCustomerColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Çıplak USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderMarketDetailColumns = [
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true },
{ name: 'order_date', label: 'Sipariş Tarihi', field: 'order_date', align: 'left', sortable: true },
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'is_overdue', label: 'Gecikti', field: row => row.is_overdue ? 'Evet' : 'Hayır', align: 'left', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const idleColumns = [ const idleColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -612,6 +861,17 @@ const idleRows = computed(() => rows.value
)) ))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))) .sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
const visibleOrderAnalysisRows = computed(() => {
if (orderAnalysisMode.value === 'market') return orderMarketRows.value
if (orderAnalysisMode.value === 'customer') return orderCustomerRows.value
return orderAnalysisRows.value
})
const visibleOrderAnalysisColumns = computed(() => {
if (orderAnalysisMode.value === 'market' || orderAnalysisMode.value === 'customer') return orderGroupColumns
return orderAnalysisColumns
})
const performanceCards = computed(() => { const performanceCards = computed(() => {
const row = performanceDialogRow.value || {} const row = performanceDialogRow.value || {}
return [ return [
@@ -642,8 +902,30 @@ const filterableColumnNames = new Set([
'urun_ana_grubu', 'urun_ana_grubu',
'urun_alt_grubu', 'urun_alt_grubu',
'market_key', 'market_key',
'performance_bucket', 'stock_qty',
'recommendation' 'sales_qty_90d',
'sales_qty_180d',
'sales_usd_90d',
'sales_usd_180d',
'stock_days_90d',
'stock_days_180d',
'avg_price_usd_90d',
'avg_price_usd_180d',
'base_price_usd',
'cost_price_usd',
'unit_profit_base_90d',
'unit_profit_cost_90d',
'unit_profit_base_180d',
'unit_profit_cost_180d',
'gross_profit_usd_90d',
'gross_profit_usd_180d',
'gross_margin_90d',
'gross_margin_180d',
'market_count_90d',
'customer_count_90d',
'sales_index_90d',
'performance_score',
'performance_bucket'
]) ])
const groupLevels = [ const groupLevels = [
@@ -659,12 +941,33 @@ const groupLevels = [
const filteredProductRows = computed(() => rows.value.filter(row => { const filteredProductRows = computed(() => rows.value.filter(row => {
return columns.every(col => { return columns.every(col => {
if (!isColumnFilterable(col.name)) return true if (!isColumnFilterable(col.name)) return true
const needle = String(columnFilters[col.name] || '').trim().toLocaleLowerCase('tr-TR') const selected = Array.isArray(columnFilters[col.name]) ? columnFilters[col.name] : []
if (!needle) return true if (!selected.length) return true
return String(rawProductCellValue(row, col.name) || '').toLocaleLowerCase('tr-TR').includes(needle) return selected.includes(columnFilterValue(row, col.name))
}) })
})) }))
const columnFilterOptionMap = computed(() => {
const out = {}
for (const col of columns) {
if (!isColumnFilterable(col.name)) continue
const seen = new Map()
for (const row of rows.value) {
const value = columnFilterValue(row, col.name)
if (!value) continue
if (!seen.has(value)) {
seen.set(value, {
label: columnFilterLabel(row, col.name),
value
})
}
}
out[col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
return out
})
const productTableRows = computed(() => { const productTableRows = computed(() => {
const out = [] const out = []
appendGroupRows(out, filteredProductRows.value, 0, []) appendGroupRows(out, filteredProductRows.value, 0, [])
@@ -709,13 +1012,16 @@ function makeGroupRow (key, level, label, groupRows) {
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'), sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'),
sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'), sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'),
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
market_count_90d: distinctCount(groupRows, 'market_key'), market_count_90d: distinctCount(groupRows, 'market_key'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'), customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'), avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'), avg_base_price_usd: averageRows(groupRows, 'base_price_usd'),
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'), avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
avg_gross_margin_90d: averageRows(groupRows, 'gross_margin_90d') gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')),
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d'))
} }
} }
@@ -732,6 +1038,12 @@ function distinctCount (sourceRows, field) {
return values.size return values.size
} }
function ratio (amount, base) {
const divisor = Number(base || 0)
if (!divisor) return 0
return Number(amount || 0) / divisor
}
function averageRows (sourceRows, field) { function averageRows (sourceRows, field) {
const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0) const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0)
if (!values.length) return 0 if (!values.length) return 0
@@ -764,6 +1076,18 @@ function isColumnFilterable (name) {
return filterableColumnNames.has(name) return filterableColumnNames.has(name)
} }
function columnFilterOptions (name) {
return columnFilterOptionMap.value[name] || []
}
function columnFilterValue (row, name) {
return String(rawProductCellValue(row, name) ?? '').trim()
}
function columnFilterLabel (row, name) {
return formatProductCell(row, name) || columnFilterValue(row, name)
}
function rawProductCellValue (row, name) { function rawProductCellValue (row, name) {
switch (name) { switch (name) {
case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || '' case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || ''
@@ -796,6 +1120,8 @@ function formatProductCell (row, name) {
case 'unit_profit_cost_90d': case 'unit_profit_cost_90d':
case 'unit_profit_base_180d': case 'unit_profit_base_180d':
case 'unit_profit_cost_180d': case 'unit_profit_cost_180d':
case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d':
return formatMoney(row[name], 'USD') return formatMoney(row[name], 'USD')
case 'gross_margin_90d': case 'gross_margin_90d':
case 'gross_margin_180d': case 'gross_margin_180d':
@@ -820,6 +1146,63 @@ function normalizeRow (row) {
} }
} }
function normalizeGeneralRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
...row,
row_key: `general|${product}|${color}|${yaka}|${market}`
}
}
function normalizeOrderAnalysisRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
...row,
row_key: `orders|${product}|${color}|${yaka}|${market}`
}
}
function normalizeOrderGroupRow (row) {
const key = String(row?.group_key || '').trim()
const mode = String(row?.breakdown || '').trim()
return {
...row,
row_key: `order-group|${mode}|${key}|${row?.market_key || ''}|${row?.customer_code || ''}`
}
}
function normalizeOrderProductCustomerRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
const customer = String(row?.customer_code || '').trim()
return {
...row,
row_key: `order-product-customer|${product}|${color}|${yaka}|${market}|${customer}`
}
}
function normalizeOrderMarketDetailRow (row) {
return {
...row,
row_key: `order-market-detail|${row?.market_key || ''}|${row?.customer_code || ''}|${row?.order_number || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}`
}
}
function orderGroupLabel (row) {
if (row?.breakdown === 'customer') {
return [row.customer_code, row.customer_name].filter(Boolean).join(' - ') || '-'
}
return row?.group_key || row?.market_key || '-'
}
function productImageKey (row) { function productImageKey (row) {
const product = String(row?.product_code || '').trim() const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim() const color = String(row?.color_code || '').trim()
@@ -961,22 +1344,18 @@ async function openPerformanceDialog (row) {
async function reload () { async function reload () {
loading.value = true loading.value = true
try { try {
const [summaryResp, listResp, marketResp, countryResp, customerResp] = await Promise.all([ const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp] = await Promise.all([
api.get('/pricing/product-performance/summary', { timeout: 60000 }), api.get('/pricing/product-performance/summary', { timeout: 60000 }),
api.get('/pricing/product-performance', { api.get('/pricing/product-performance', {
params: { params: {
page: pagination.value.page, page: pagination.value.page,
limit: pagination.value.rowsPerPage, limit: pagination.value.rowsPerPage,
q: filters.q || undefined,
product_code: filters.product_code || undefined,
kategori: filters.kategori || undefined,
seri: filters.seri || undefined,
bucket: filters.bucket || undefined,
sort_by: pagination.value.sortBy || undefined, sort_by: pagination.value.sortBy || undefined,
descending: pagination.value.descending ? 'true' : 'false' descending: pagination.value.descending ? 'true' : 'false'
}, },
timeout: 60000 timeout: 60000
}), }),
api.get('/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/markets', { params: { limit: 200 }, timeout: 60000 }), api.get('/pricing/product-performance/markets', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/countries', { params: { limit: 200 }, timeout: 60000 }), api.get('/pricing/product-performance/countries', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/customers', { api.get('/pricing/product-performance/customers', {
@@ -986,6 +1365,7 @@ async function reload () {
]) ])
summary.value = summaryResp?.data || {} summary.value = summaryResp?.data || {}
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow) rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow)
generalRows.value = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow)
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : [] marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({ countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
...row, ...row,
@@ -996,7 +1376,13 @@ async function reload () {
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}` customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
})) }))
pagination.value.rowsNumber = Number(listResp?.data?.total_count || 0) pagination.value.rowsNumber = Number(listResp?.data?.total_count || 0)
void primeProductImages(rows.value) if (activeTab.value === 'order_product_customers') {
await loadOrderProductCustomers(false)
}
if (activeTab.value === 'order_market_details') {
await loadOrderMarketDetails(false)
}
void primeProductImages([...rows.value, ...generalRows.value])
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally { } finally {
@@ -1004,6 +1390,53 @@ async function reload () {
} }
} }
async function loadOrderProductCustomers (showLoading = true) {
if (showLoading) loading.value = true
try {
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true
void primeProductImages(orderProductCustomerRows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderMarketDetails (showLoading = true) {
if (showLoading) loading.value = true
try {
const resp = await api.get('/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa sipariş detay analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true
try {
const [productResp, marketResp, customerResp] = await Promise.all([
api.get('/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
])
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
orderAnalysisLoaded.value = true
void primeProductImages(orderAnalysisRows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function runDelta () { async function runDelta () {
refreshing.value = true refreshing.value = true
try { try {
@@ -1040,6 +1473,15 @@ function bucketColor (value) {
} }
} }
watch(activeTab, tab => {
if (tab === 'order_product_customers' && !orderProductCustomersLoaded.value) {
void loadOrderProductCustomers(true)
}
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
void loadOrderMarketDetails(true)
}
})
onMounted(reload) onMounted(reload)
</script> </script>