Files
bssapp/svc/queries/product_performance.go
T

2250 lines
89 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package queries
import (
"bssapp-backend/db"
"bssapp-backend/models"
"context"
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/lib/pq"
)
type ProductPerformanceFilters struct {
Search string
ProductCode string
MarketKey string
Kategori string
Seri string
Bucket string
SortBy string
Descending bool
Limit int
Page int
}
type ProductPerformanceRefreshRequest struct {
Mode string
Stage string
ResumeAfter int
SkipDelete bool
ProductPrefix string
StartDate time.Time
EndDate time.Time
}
type ProductPerformanceRefreshResult struct {
Mode string `json:"mode"`
Stage string `json:"stage"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
SalesRows int `json:"sales_rows"`
StockRows int `json:"stock_rows"`
KpiRows int `json:"kpi_rows"`
DurationMS int64 `json:"duration_ms"`
}
func EnsureProductPerformanceTables(pg *sql.DB) error {
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily (
sales_date DATE NOT NULL,
product_code TEXT NOT NULL,
color_code TEXT NOT NULL DEFAULT '',
yaka_kodu TEXT NOT NULL DEFAULT '',
item_description TEXT NOT NULL DEFAULT '',
kategori TEXT NOT NULL DEFAULT '',
seri TEXT NOT NULL DEFAULT '',
yas_grubu TEXT NOT NULL DEFAULT '',
askili_yan TEXT NOT NULL DEFAULT '',
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
urun_ana_grubu TEXT NOT NULL DEFAULT '',
urun_alt_grubu TEXT NOT NULL DEFAULT '',
market_key TEXT NOT NULL DEFAULT '',
channel_code TEXT NOT NULL DEFAULT '',
customer_country TEXT NOT NULL DEFAULT '',
customer_segment TEXT NOT NULL DEFAULT '',
customer_code TEXT NOT NULL DEFAULT '',
customer_name TEXT NOT NULL DEFAULT '',
sales_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_tl NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd NUMERIC(18,4) NOT NULL DEFAULT 0,
avg_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
invoice_line_count INTEGER NOT NULL DEFAULT 0,
invoice_count INTEGER NOT NULL DEFAULT 0,
customer_count INTEGER NOT NULL DEFAULT 0,
last_ref_number TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY
(sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code)
)`,
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_code TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'pk_mk_product_performance_sales_daily'
AND conrelid = 'mk_product_performance_sales_daily'::regclass
) THEN
ALTER TABLE mk_product_performance_sales_daily DROP CONSTRAINT pk_mk_product_performance_sales_daily;
END IF;
ALTER TABLE mk_product_performance_sales_daily
ADD CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY
(sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code);
END $$`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_product_date ON mk_product_performance_sales_daily (product_code, sales_date DESC)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_market ON mk_product_performance_sales_daily (market_key, sales_date DESC)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_customer ON mk_product_performance_sales_daily (customer_code, sales_date DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_product_performance_stock_daily (
stock_date DATE NOT NULL,
product_code TEXT NOT NULL,
color_code TEXT NOT NULL DEFAULT '',
yaka_kodu TEXT NOT NULL DEFAULT '',
stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
kpi_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
kpi_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_movement_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
production_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
purchase_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
consumption_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
count_diff_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_mk_product_performance_stock_daily PRIMARY KEY
(stock_date, product_code, color_code, yaka_kodu)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_stock_product_date ON mk_product_performance_stock_daily (product_code, stock_date DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim (
product_code TEXT PRIMARY KEY,
cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
last_pricing_date DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`
CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
kpi_date DATE NOT NULL,
product_code TEXT NOT NULL,
color_code TEXT NOT NULL DEFAULT '',
yaka_kodu TEXT NOT NULL DEFAULT '',
item_description TEXT NOT NULL DEFAULT '',
kategori TEXT NOT NULL DEFAULT '',
seri TEXT NOT NULL DEFAULT '',
yas_grubu TEXT NOT NULL DEFAULT '',
askili_yan TEXT NOT NULL DEFAULT '',
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
urun_ana_grubu TEXT NOT NULL DEFAULT '',
urun_alt_grubu TEXT NOT NULL DEFAULT '',
market_key TEXT NOT NULL DEFAULT '',
stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
market_count_90d INTEGER NOT NULL DEFAULT 0,
customer_count_90d INTEGER NOT NULL DEFAULT 0,
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
performance_score NUMERIC(18,6) NOT NULL DEFAULT 0,
performance_bucket TEXT NOT NULL DEFAULT '',
recommendation TEXT NOT NULL DEFAULT '',
last_sale_date DATE,
last_ref_number TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_mk_product_performance_kpi_daily PRIMARY KEY
(kpi_date, product_code, color_code, yaka_kodu, market_key)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_bucket ON mk_product_performance_kpi_daily (kpi_date DESC, performance_bucket)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_market ON mk_product_performance_kpi_daily (kpi_date DESC, market_key, sales_index_90d DESC)`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil {
return err
}
}
return nil
}
func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
started := time.Now()
stage := productPerformanceRefreshStage(req.Stage)
productPrefix := productPerformanceProductPrefix(req.ProductPrefix)
log.Printf("[ProductPerformanceRefresh] start mode=%s stage=%s prefix=%s start=%s end=%s", req.Mode, stage, productPrefix, req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02"))
if pg == nil {
return ProductPerformanceRefreshResult{}, fmt.Errorf("postgres db nil")
}
if db.MssqlDB == nil {
return ProductPerformanceRefreshResult{}, fmt.Errorf("mssql db nil")
}
log.Printf("[ProductPerformanceRefresh] ensure tables start")
if err := EnsureProductPerformanceTables(pg); err != nil {
return ProductPerformanceRefreshResult{}, err
}
log.Printf("[ProductPerformanceRefresh] ensure tables done")
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" {
mode = "delta"
}
if req.EndDate.IsZero() {
req.EndDate = time.Now()
}
if req.StartDate.IsZero() {
if mode == "full" {
req.StartDate = time.Date(2022, 1, 1, 0, 0, 0, 0, req.EndDate.Location())
} else {
req.StartDate = req.EndDate.AddDate(0, 0, -45)
}
}
req.StartDate = dateOnly(req.StartDate)
req.EndDate = dateOnly(req.EndDate)
shouldRun := func(name string) bool {
if stage == "all" {
return true
}
order := map[string]int{
"sales": 1,
"stock": 2,
"price": 3,
"kpi": 4,
}
return order[name] >= order[stage]
}
runStage := func(stage string, fn func(*sql.Tx) error) error {
log.Printf("[ProductPerformanceRefresh] %s tx begin", stage)
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
log.Printf("[ProductPerformanceRefresh] %s tx commit start", stage)
if err := tx.Commit(); err != nil {
return err
}
log.Printf("[ProductPerformanceRefresh] %s tx commit done elapsed=%s", stage, time.Since(started).Round(time.Second))
return nil
}
var salesRows int
if shouldRun("sales") {
if err := runStage("sales", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] delete sales cache start")
deleteSQL := `DELETE FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN $1 AND $2`
deleteArgs := []any{req.StartDate, req.EndDate}
if productPrefix != "" {
deleteSQL += ` AND product_code ILIKE $3 || '%'`
deleteArgs = append(deleteArgs, productPrefix)
}
if _, err := tx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil {
return err
}
log.Printf("[ProductPerformanceRefresh] delete sales cache done")
log.Printf("[ProductPerformanceRefresh] sales refresh start")
rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate, productPrefix)
if err != nil {
return err
}
salesRows = rows
log.Printf("[ProductPerformanceRefresh] sales refresh done rows=%d elapsed=%s", salesRows, time.Since(started).Round(time.Second))
return nil
}); err != nil {
return ProductPerformanceRefreshResult{}, err
}
} else {
log.Printf("[ProductPerformanceRefresh] sales skipped stage=%s", stage)
}
var stockRows int
if shouldRun("stock") {
rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter, productPrefix)
if err != nil {
return ProductPerformanceRefreshResult{}, err
}
stockRows = rows
} else {
log.Printf("[ProductPerformanceRefresh] stock skipped stage=%s", stage)
}
if shouldRun("price") {
if err := runStage("price", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] price refresh start")
if err := refreshProductPerformancePrices(ctx, tx, productPrefix); err != nil {
return err
}
log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second))
return nil
}); err != nil {
return ProductPerformanceRefreshResult{}, err
}
} else {
log.Printf("[ProductPerformanceRefresh] price skipped stage=%s", stage)
}
var kpiRows int
if shouldRun("kpi") {
if err := runStage("kpi", func(tx *sql.Tx) error {
log.Printf("[ProductPerformanceRefresh] kpi rebuild start")
rows, err := RebuildProductPerformanceKPI(ctx, tx, req.EndDate)
if err != nil {
return err
}
kpiRows = rows
log.Printf("[ProductPerformanceRefresh] kpi rebuild done rows=%d elapsed=%s", kpiRows, time.Since(started).Round(time.Second))
return nil
}); err != nil {
return ProductPerformanceRefreshResult{}, err
}
} else {
log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage)
}
log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second))
return ProductPerformanceRefreshResult{
Mode: mode,
Stage: stage,
StartDate: req.StartDate.Format("2006-01-02"),
EndDate: req.EndDate.Format("2006-01-02"),
SalesRows: salesRows,
StockRows: stockRows,
KpiRows: kpiRows,
DurationMS: time.Since(started).Milliseconds(),
}, nil
}
func productPerformanceRefreshStage(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "all", "full":
return "all"
case "sales", "stock", "price", "kpi":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "all"
}
}
func productPerformanceProductPrefix(raw string) string {
prefix := strings.ToUpper(strings.TrimSpace(raw))
switch prefix {
case "", "S", "O", "N", "X", "I", "A":
return prefix
default:
return ""
}
}
func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) {
log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate, productPrefix)
if err != nil {
return 0, err
}
defer rows.Close()
log.Printf("[ProductPerformanceRefresh] sales mssql query returned, postgres insert start")
count := 0
for rows.Next() {
var r productPerformanceSalesDaily
if err := rows.Scan(
&r.SalesDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.ChannelCode,
&r.CustomerCountry, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesTL, &r.SalesUSD,
&r.AvgPriceUSD, &r.InvoiceLineCount, &r.InvoiceCount, &r.CustomerCount, &r.LastRefNumber,
); err != nil {
return count, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO mk_product_performance_sales_daily (
sales_date, product_code, color_code, yaka_kodu, item_description,
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, channel_code,
customer_country, customer_segment, customer_code, customer_name, sales_qty, sales_tl, sales_usd,
avg_price_usd, invoice_line_count, invoice_count, customer_count, last_ref_number, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,now()
)
ON CONFLICT (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code)
DO UPDATE SET
item_description=EXCLUDED.item_description,
kategori=EXCLUDED.kategori,
seri=EXCLUDED.seri,
yas_grubu=EXCLUDED.yas_grubu,
askili_yan=EXCLUDED.askili_yan,
urun_ilk_grubu=EXCLUDED.urun_ilk_grubu,
urun_ana_grubu=EXCLUDED.urun_ana_grubu,
urun_alt_grubu=EXCLUDED.urun_alt_grubu,
channel_code=EXCLUDED.channel_code,
customer_name=EXCLUDED.customer_name,
sales_qty=EXCLUDED.sales_qty,
sales_tl=EXCLUDED.sales_tl,
sales_usd=EXCLUDED.sales_usd,
avg_price_usd=EXCLUDED.avg_price_usd,
invoice_line_count=EXCLUDED.invoice_line_count,
invoice_count=EXCLUDED.invoice_count,
customer_count=EXCLUDED.customer_count,
last_ref_number=EXCLUDED.last_ref_number,
updated_at=now()
`, r.SalesDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.ItemDescription,
r.Kategori, r.Seri, r.YasGrubu, r.AskiliYan, r.UrunIlkGrubu, r.UrunAnaGrubu, r.UrunAltGrubu, r.MarketKey, r.ChannelCode,
r.CustomerCountry, r.CustomerSegment, r.CustomerCode, r.CustomerName, r.SalesQty, r.SalesTL, r.SalesUSD,
r.AvgPriceUSD, r.InvoiceLineCount, r.InvoiceCount, r.CustomerCount, r.LastRefNumber); err != nil {
return count, err
}
count++
if count%5000 == 0 {
log.Printf("[ProductPerformanceRefresh] sales inserted rows=%d", count)
}
}
return count, rows.Err()
}
func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) {
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate, productPrefix)
if err != nil {
return 0, err
}
defer rows.Close()
log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres insert start")
count := 0
for rows.Next() {
var r productPerformanceStockDaily
if err := rows.Scan(
&r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty,
&r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty,
&r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty,
); err != nil {
return count, err
}
if err := insertProductPerformanceStock(ctx, tx, r); err != nil {
return count, err
}
count++
if count%5000 == 0 {
log.Printf("[ProductPerformanceRefresh] stock inserted rows=%d", count)
}
}
return count, rows.Err()
}
func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, startDate, endDate, started time.Time, skipDelete bool, resumeAfter int, productPrefix string) (int, error) {
if skipDelete {
log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter)
} else {
log.Printf("[ProductPerformanceRefresh] stock delete tx begin")
deleteTx, err := pg.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer deleteTx.Rollback()
log.Printf("[ProductPerformanceRefresh] delete stock cache start")
deleteSQL := `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2`
deleteArgs := []any{startDate, endDate}
if productPrefix != "" {
deleteSQL += ` AND product_code ILIKE $3 || '%'`
deleteArgs = append(deleteArgs, productPrefix)
}
if _, err := deleteTx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil {
return 0, err
}
log.Printf("[ProductPerformanceRefresh] delete stock cache done")
log.Printf("[ProductPerformanceRefresh] stock delete tx commit start")
if err := deleteTx.Commit(); err != nil {
return 0, err
}
log.Printf("[ProductPerformanceRefresh] stock delete tx commit done elapsed=%s", time.Since(started).Round(time.Second))
}
log.Printf("[ProductPerformanceRefresh] stock refresh start")
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate, productPrefix)
if err != nil {
return 0, err
}
defer rows.Close()
log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres chunk insert start")
const chunkSize = 5000
count := 0
seen := 0
chunkRows := 0
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
for rows.Next() {
var r productPerformanceStockDaily
if err := rows.Scan(
&r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty,
&r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty,
&r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty,
); err != nil {
return count, err
}
seen++
if resumeAfter > 0 && seen <= resumeAfter {
if seen%50000 == 0 || seen == resumeAfter {
log.Printf("[ProductPerformanceRefresh] stock resume skipped rows=%d", seen)
}
continue
}
if err := insertProductPerformanceStock(ctx, tx, r); err != nil {
return count, err
}
count++
chunkRows++
if chunkRows >= chunkSize {
log.Printf("[ProductPerformanceRefresh] stock chunk commit start inserted_rows=%d scanned_rows=%d", count, seen)
if err := tx.Commit(); err != nil {
return count, err
}
log.Printf("[ProductPerformanceRefresh] stock chunk commit done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
tx, err = pg.BeginTx(ctx, nil)
if err != nil {
return count, err
}
chunkRows = 0
}
}
if err := rows.Err(); err != nil {
return count, err
}
if chunkRows > 0 {
log.Printf("[ProductPerformanceRefresh] stock final chunk commit start inserted_rows=%d scanned_rows=%d", count, seen)
if err := tx.Commit(); err != nil {
return count, err
}
log.Printf("[ProductPerformanceRefresh] stock final chunk commit done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
} else {
_ = tx.Rollback()
}
log.Printf("[ProductPerformanceRefresh] stock refresh done inserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
return count, nil
}
type productPerformanceStockExec interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func insertProductPerformanceStock(ctx context.Context, exec productPerformanceStockExec, r productPerformanceStockDaily) error {
_, err := exec.ExecContext(ctx, `
INSERT INTO mk_product_performance_stock_daily (
stock_date, product_code, color_code, yaka_kodu, stock_qty, in_qty, out_qty,
kpi_in_qty, kpi_out_qty, sales_movement_qty, production_in_qty, purchase_in_qty,
consumption_out_qty, count_diff_qty, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now())
ON CONFLICT (stock_date, product_code, color_code, yaka_kodu)
DO UPDATE SET
stock_qty=EXCLUDED.stock_qty,
in_qty=EXCLUDED.in_qty,
out_qty=EXCLUDED.out_qty,
kpi_in_qty=EXCLUDED.kpi_in_qty,
kpi_out_qty=EXCLUDED.kpi_out_qty,
sales_movement_qty=EXCLUDED.sales_movement_qty,
production_in_qty=EXCLUDED.production_in_qty,
purchase_in_qty=EXCLUDED.purchase_in_qty,
consumption_out_qty=EXCLUDED.consumption_out_qty,
count_diff_qty=EXCLUDED.count_diff_qty,
updated_at=now()
`, r.StockDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.StockQty,
r.InQty, r.OutQty, r.KpiInQty, r.KpiOutQty, r.SalesMovementQty,
r.ProductionInQty, r.PurchaseInQty, r.ConsumptionOutQty, r.CountDiffQty)
return err
}
func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx, productPrefix string) error {
log.Printf("[ProductPerformanceRefresh] price mssql query start prefix=%s", productPrefix)
rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL(), productPrefix)
if err != nil {
return err
}
defer rows.Close()
log.Printf("[ProductPerformanceRefresh] price mssql query returned, postgres upsert start")
count := 0
for rows.Next() {
var code string
var cost, usd, tryPrice float64
var lastPricing sql.NullTime
if err := rows.Scan(&code, &cost, &usd, &tryPrice, &lastPricing); err != nil {
return err
}
var lp any
if lastPricing.Valid {
lp = lastPricing.Time
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO mk_product_performance_price_dim (product_code, cost_price_usd, base_price_usd, base_price_try, last_pricing_date, updated_at)
VALUES ($1,$2,$3,$4,$5,now())
ON CONFLICT (product_code) DO UPDATE SET
cost_price_usd=EXCLUDED.cost_price_usd,
base_price_usd=EXCLUDED.base_price_usd,
base_price_try=EXCLUDED.base_price_try,
last_pricing_date=EXCLUDED.last_pricing_date,
updated_at=now()
`, strings.TrimSpace(code), cost, usd, tryPrice, lp); err != nil {
return err
}
count++
if count%5000 == 0 {
log.Printf("[ProductPerformanceRefresh] price upserted rows=%d", count)
}
}
log.Printf("[ProductPerformanceRefresh] price upsert done rows=%d", count)
return rows.Err()
}
func RebuildProductPerformanceKPI(ctx context.Context, exec interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, kpiDate time.Time) (int, error) {
kpiDate = dateOnly(kpiDate)
if _, err := exec.ExecContext(ctx, `DELETE FROM mk_product_performance_kpi_daily WHERE kpi_date=$1`, kpiDate); err != nil {
return 0, err
}
res, err := exec.ExecContext(ctx, productPerformanceKPISQL(), kpiDate)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanceFilters) ([]models.ProductPerformanceRow, int, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
page := f.Page
if page <= 0 {
page = 1
}
where, args := productPerformanceWhere(f)
countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where
var total int
if err := pg.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, limit, (page-1)*limit)
orderBy := productPerformanceOrderBy(f.SortBy, f.Descending)
rows, err := pg.QueryContext(ctx, `
SELECT
to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description,
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d,
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, avg_daily_sales_90d, avg_daily_sales_180d,
stock_days_90d, stock_days_180d, avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d, market_count_90d, customer_count_90d, sales_index_90d,
price_index_90d, margin_index_90d, performance_score, performance_bucket,
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+`
ORDER BY `+orderBy+`
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]models.ProductPerformanceRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceRow
if err := rows.Scan(
&r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty,
&r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730,
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.AvgDailySales90, &r.AvgDailySales180,
&r.StockDays90, &r.StockDays180, &r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
&r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180,
&r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180, &r.MarketCount90, &r.CustomerCount90, &r.SalesIndex90,
&r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
); err != nil {
return nil, 0, err
}
out = append(out, r)
}
return out, total, rows.Err()
}
func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return models.ProductPerformanceSummary{}, err
}
var s models.ProductPerformanceSummary
err := pg.QueryRowContext(ctx, `
WITH Latest AS (
SELECT MAX(kpi_date) AS kpi_date
FROM mk_product_performance_kpi_daily
),
KPI AS (
SELECT *
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest)
),
Sales90 AS (
SELECT
COUNT(DISTINCT market_key) AS market_count_90d,
COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d
FROM mk_product_performance_sales_daily
WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest)
)
SELECT
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
COUNT(*),
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'),
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'),
COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'),
COALESCE(SUM(stock_qty),0),
COALESCE(SUM(stock_qty * cost_price_usd),0),
COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0),
COALESCE(SUM(sales_qty_90d),0),
COALESCE(SUM(sales_usd_90d),0),
COALESCE(SUM(gross_profit_usd_90d),0),
COALESCE(MAX(Sales90.market_count_90d),0)::integer,
COALESCE(MAX(Sales90.customer_count_90d),0)::integer,
COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
FROM KPI
CROSS JOIN Sales90
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
return s, err
}
func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceGeneralRow, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 1000 {
limit = 500
}
rows, err := pg.QueryContext(ctx, `
WITH Bounds AS (
SELECT
DATE '2022-01-01' AS period_start,
COALESCE(
(SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily),
(SELECT MAX(sales_date) FROM mk_product_performance_sales_daily),
current_date
) AS period_end
),
LatestStockDate AS (
SELECT MAX(stock_date) AS stock_date
FROM mk_product_performance_stock_daily
),
SalesAgg AS (
SELECT
product_code,
color_code,
yaka_kodu,
market_key,
MAX(item_description) AS item_description,
MAX(kategori) AS kategori,
MAX(seri) AS seri,
MAX(yas_grubu) AS yas_grubu,
MAX(askili_yan) AS askili_yan,
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
MAX(urun_ana_grubu) AS urun_ana_grubu,
MAX(urun_alt_grubu) AS urun_alt_grubu,
MIN(sales_date) AS first_sale_date,
MAX(sales_date) AS last_sale_date,
MAX(last_ref_number) AS last_ref_number,
COALESCE(SUM(sales_qty),0) AS sales_qty_total,
COALESCE(SUM(sales_usd),0) AS sales_usd_total,
COALESCE(COUNT(DISTINCT NULLIF(customer_code, '-')),0)::integer AS customer_count_total,
COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total
FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
GROUP BY product_code, color_code, yaka_kodu, market_key
),
StockAgg AS (
SELECT
s.product_code,
s.color_code,
s.yaka_kodu,
COALESCE(SUM(s.stock_qty),0) AS stock_qty
FROM mk_product_performance_stock_daily s
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
GROUP BY s.product_code, s.color_code, s.yaka_kodu
),
StockOnly AS (
SELECT
s.product_code,
s.color_code,
s.yaka_kodu,
'STOK'::text AS market_key,
MAX(s.item_description) AS item_description,
MAX(s.kategori) AS kategori,
MAX(s.seri) AS seri,
MAX(s.yas_grubu) AS yas_grubu,
MAX(s.askili_yan) AS askili_yan,
MAX(s.urun_ilk_grubu) AS urun_ilk_grubu,
MAX(s.urun_ana_grubu) AS urun_ana_grubu,
MAX(s.urun_alt_grubu) AS urun_alt_grubu,
NULL::date AS first_sale_date,
NULL::date AS last_sale_date,
''::text AS last_ref_number,
0::numeric AS sales_qty_total,
0::numeric AS sales_usd_total,
0::integer AS customer_count_total,
0::integer AS invoice_count_total
FROM mk_product_performance_stock_daily s
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
AND NOT EXISTS (
SELECT 1
FROM SalesAgg a
WHERE a.product_code = s.product_code
AND a.color_code = s.color_code
AND a.yaka_kodu = s.yaka_kodu
)
GROUP BY s.product_code, s.color_code, s.yaka_kodu
),
BaseRows AS (
SELECT * FROM SalesAgg
UNION ALL
SELECT * FROM StockOnly
),
Spread AS (
SELECT
product_code,
color_code,
yaka_kodu,
COUNT(DISTINCT NULLIF(market_key, 'STOK'))::integer AS market_count_total,
COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all
FROM mk_product_performance_sales_daily, Bounds
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
GROUP BY product_code, color_code, yaka_kodu
),
Scored AS (
SELECT
Bounds.period_start,
Bounds.period_end,
b.product_code,
b.color_code,
b.yaka_kodu,
b.item_description,
b.kategori,
b.seri,
b.yas_grubu,
b.askili_yan,
b.urun_ilk_grubu,
b.urun_ana_grubu,
b.urun_alt_grubu,
b.market_key,
COALESCE(st.stock_qty, 0) AS stock_qty,
b.sales_qty_total,
b.sales_usd_total,
b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)) AS avg_daily_sales_total,
CASE
WHEN b.sales_qty_total <= 0 THEN 9999
ELSE COALESCE(st.stock_qty, 0) / NULLIF(b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)), 0)
END AS stock_days_total,
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE b.sales_usd_total / NULLIF(b.sales_qty_total,0) END AS avg_price_usd_total,
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
COALESCE(pd.base_price_usd,0) AS base_price_usd,
b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0)) AS gross_profit_usd_total,
CASE WHEN b.sales_usd_total <= 0 THEN 0 ELSE (b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0))) / NULLIF(b.sales_usd_total,0) END AS gross_margin_total,
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.cost_price_usd,0) END AS unit_profit_cost_total,
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.base_price_usd,0) END AS unit_profit_base_total,
COALESCE(sp.market_count_total,0) AS market_count_total,
COALESCE(sp.customer_count_total_all, b.customer_count_total) AS customer_count_total,
b.invoice_count_total,
CASE
WHEN AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu) <= 0 THEN 0
ELSE b.sales_qty_total / NULLIF(AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu),0)
END AS sales_index_total,
b.first_sale_date,
b.last_sale_date,
b.last_ref_number
FROM BaseRows b
CROSS JOIN Bounds
LEFT JOIN StockAgg st
ON st.product_code = b.product_code
AND st.color_code = b.color_code
AND st.yaka_kodu = b.yaka_kodu
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = b.product_code
LEFT JOIN Spread sp
ON sp.product_code = b.product_code
AND sp.color_code = b.color_code
AND sp.yaka_kodu = b.yaka_kodu
)
SELECT
to_char(period_start,'YYYY-MM-DD'),
to_char(period_end,'YYYY-MM-DD'),
product_code, color_code, yaka_kodu, item_description,
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key,
stock_qty, sales_qty_total, sales_usd_total, avg_daily_sales_total, stock_days_total, avg_price_usd_total,
cost_price_usd, base_price_usd, gross_profit_usd_total, gross_margin_total, unit_profit_cost_total, unit_profit_base_total,
market_count_total, customer_count_total, invoice_count_total, sales_index_total,
ROUND((
LEAST(35, GREATEST(0, sales_index_total) * 18)
+ LEAST(25, GREATEST(0, gross_margin_total) * 50)
+ LEAST(20, customer_count_total * 1.5)
+ LEAST(10, market_count_total * 2.5)
+ CASE
WHEN sales_qty_total > 0 AND stock_days_total BETWEEN 20 AND 180 THEN 10
WHEN sales_qty_total > 0 AND stock_days_total > 365 THEN -10
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN -15
ELSE 0
END
)::numeric, 4) AS performance_score,
CASE
WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP'
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI'
WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI'
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'YILDIZ_URUN'
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI'
ELSE 'TAKIP'
END AS performance_bucket,
CASE
WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.'
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.'
WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.'
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.'
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.'
ELSE 'Izleme ve piyasa bazli aksiyon.'
END AS recommendation,
COALESCE(to_char(first_sale_date,'YYYY-MM-DD'),''),
COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''),
COALESCE(last_ref_number,'')
FROM Scored
ORDER BY performance_score DESC, sales_usd_total DESC, product_code ASC, color_code ASC, yaka_kodu ASC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceGeneralRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceGeneralRow
if err := rows.Scan(
&r.PeriodStart, &r.PeriodEnd, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey,
&r.StockQty, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.AvgDailySalesTotal, &r.StockDaysTotal, &r.AvgPriceUSDTotal,
&r.CostPriceUSD, &r.BasePriceUSD, &r.GrossProfitUSDTotal, &r.GrossMarginTotal, &r.UnitProfitCostTotal, &r.UnitProfitBaseTotal,
&r.MarketCountTotal, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesIndexTotal, &r.PerformanceScore,
&r.PerformanceBucket, &r.Recommendation, &r.FirstSaleDate, &r.LastSaleDate, &r.LastRefNumber,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
}
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 1000 {
limit = 500
}
rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS (
SELECT
OrderDate = CAST(h.OrderDate AS date),
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date),
h.OrderHeaderID,
h.OrderNumber,
h.CurrAccCode,
ProductCode = LTRIM(RTRIM(l.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))),
ItemDescription = dbo.HG_Temizlik(ISNULL((
SELECT ItemDescription
FROM cdItemDesc WITH(NOLOCK)
WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemDesc.ItemCode = l.ItemCode
AND cdItemDesc.LangCode = 'TR'
), SPACE(0))),
Kategori = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 42
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 42
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
AND prItemAttribute.ItemCode = l.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
Seri = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 2
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 2
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
AND prItemAttribute.ItemCode = l.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
YasGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 44
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 44
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
AND prItemAttribute.ItemCode = l.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
AskiliYan = dbo.HG_Temizlik(ISNULL((
SELECT TOP 1 ProductAtt45
FROM ProductAttributesFilter WITH(NOLOCK)
WHERE ProductAttributesFilter.ItemCode = l.ItemCode
), SPACE(0))),
UrunAnaGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 1
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 1
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
AND prItemAttribute.ItemCode = l.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
UrunAltGrubu = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdItemAttributeDesc WITH(NOLOCK)
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
AND cdItemAttributeDesc.AttributeTypeCode = 2
AND cdItemAttributeDesc.AttributeCode = (
SELECT TOP 1 AttributeCode
FROM prItemAttribute WITH(NOLOCK)
WHERE AttributeTypeCode = 2
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
AND prItemAttribute.ItemCode = l.ItemCode
)
AND cdItemAttributeDesc.LangCode = 'TR'
), SPACE(0))),
MarketKey = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
AND cdCurrAccAttributeDesc.LangCode = 'TR'
), SPACE(0))),
Qty = ISNULL(l.Qty1, 0),
AmountUSD = CASE
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
ELSE 0
END
FROM dbo.trOrderHeader h WITH(NOLOCK)
INNER JOIN dbo.trOrderLine l WITH(NOLOCK)
ON l.OrderHeaderID = h.OrderHeaderID
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK)
ON c.OrderLineID = l.OrderLineID
AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK)
ON caf.CurrAccTypeCode = h.CurrAccTypeCode
AND caf.CurrAccCode = h.CurrAccCode
OUTER APPLY (
SELECT TOP 1 Rate
FROM dbo.AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = 'USD'
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
ORDER BY Date DESC
) usd
OUTER APPLY (
SELECT TOP 1 Rate
FROM dbo.AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = h.DocCurrencyCode
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
ORDER BY Date DESC
) cur
WHERE ISNULL(h.IsCancelOrder, 0) = 0
AND ISNULL(h.IsClosed, 0) = 0
AND h.OrderTypeCode = 1
AND h.ProcessCode = 'WS'
AND ISNULL(l.IsClosed, 0) = 0
AND l.ItemTypeCode = 1
AND ISNULL(l.Qty1, 0) > 0
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
AND (
l.ItemCode LIKE 'S%'
OR l.ItemCode LIKE 'O%'
OR l.ItemCode LIKE 'N%'
OR l.ItemCode LIKE 'X%'
OR l.ItemCode LIKE 'I%'
OR l.ItemCode LIKE 'A%'
)
),
Spread AS (
SELECT
ProductCode,
ColorCode,
YakaKodu,
MarketCount = COUNT(DISTINCT NULLIF(MarketKey, '')),
CustomerCount = COUNT(DISTINCT NULLIF(CurrAccCode, ''))
FROM OpenOrderLines
GROUP BY ProductCode, ColorCode, YakaKodu
)
SELECT TOP (@p1)
o.ProductCode,
o.ColorCode,
o.YakaKodu,
ItemDescription = MAX(o.ItemDescription),
Kategori = MAX(o.Kategori),
Seri = MAX(o.Seri),
YasGrubu = MAX(o.YasGrubu),
AskiliYan = MAX(o.AskiliYan),
UrunAnaGrubu = MAX(o.UrunAnaGrubu),
UrunAltGrubu = MAX(o.UrunAltGrubu),
o.MarketKey,
OrderQty = SUM(o.Qty),
OrderUSD = SUM(o.AmountUSD),
AvgOrderPriceUSD = CASE WHEN SUM(o.Qty) = 0 THEN 0 ELSE SUM(o.AmountUSD) / NULLIF(SUM(o.Qty), 0) END,
MarketCount = MAX(s.MarketCount),
CustomerCount = COUNT(DISTINCT NULLIF(o.CurrAccCode, '')),
OrderCount = COUNT(DISTINCT o.OrderHeaderID),
LineCount = COUNT(*),
FirstOrderDate = CONVERT(varchar, MIN(o.OrderDate), 23),
LastOrderDate = CONVERT(varchar, MAX(o.OrderDate), 23),
EarliestDueDate = CONVERT(varchar, MIN(o.DueDate), 23),
OverdueQty = SUM(CASE WHEN o.DueDate < CAST(GETDATE() AS date) THEN o.Qty ELSE 0 END)
FROM OpenOrderLines o
LEFT JOIN Spread s
ON s.ProductCode = o.ProductCode
AND s.ColorCode = o.ColorCode
AND s.YakaKodu = o.YakaKodu
GROUP BY o.ProductCode, o.ColorCode, o.YakaKodu, o.MarketKey
ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.YakaKodu
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceOrderAnalysisRow, 0, limit)
productCodes := make([]string, 0, limit)
stockKeys := make([]string, 0, limit)
seenProducts := map[string]bool{}
seenStockKeys := map[string]bool{}
for rows.Next() {
var r models.ProductPerformanceOrderAnalysisRow
if err := rows.Scan(
&r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan,
&r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD,
&r.MarketCount, &r.CustomerCount, &r.OrderCount, &r.LineCount, &r.FirstOrderDate, &r.LastOrderDate, &r.EarliestDueDate, &r.OverdueQty,
); err != nil {
return nil, err
}
r.UrunIlkGrubu = r.YasGrubu
out = append(out, r)
if !seenProducts[r.ProductCode] {
seenProducts[r.ProductCode] = true
productCodes = append(productCodes, r.ProductCode)
}
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
if !seenStockKeys[key] {
seenStockKeys[key] = true
stockKeys = append(stockKeys, key)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
if err != nil {
return nil, err
}
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
if err != nil {
return nil, err
}
for i := range out {
row := &out[i]
price := priceByProduct[row.ProductCode]
row.CostPriceUSD = price.cost
row.BasePriceUSD = price.base
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
row.NetStockAfterOrder = row.StockQty - row.OrderQty
row.UnitProfitCostUSD = row.AvgOrderPriceUSD - row.CostPriceUSD
row.UnitProfitBaseUSD = row.AvgOrderPriceUSD - row.BasePriceUSD
row.ExpectedProfitCostUSD = row.UnitProfitCostUSD * row.OrderQty
row.ExpectedProfitBaseUSD = row.UnitProfitBaseUSD * row.OrderQty
if row.OrderUSD != 0 {
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
}
row.PerformanceBucket, row.Recommendation = productPerformanceOrderRecommendation(*row)
}
return out, nil
}
func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceOrderGroupRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
}
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
mode := strings.ToLower(strings.TrimSpace(breakdown))
if mode != "customer" {
mode = "market"
}
if limit <= 0 || limit > 1000 {
limit = 500
}
rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS (
SELECT
h.OrderHeaderID,
h.CurrAccCode,
CustomerName = ISNULL(cad.CurrAccDescription, ''),
ProductCode = LTRIM(RTRIM(l.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))),
MarketKey = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
AND cdCurrAccAttributeDesc.LangCode = 'TR'
), SPACE(0))),
Qty = ISNULL(l.Qty1, 0),
AmountUSD = CASE
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
ELSE 0
END,
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date)
FROM dbo.trOrderHeader h WITH(NOLOCK)
INNER JOIN dbo.trOrderLine l WITH(NOLOCK)
ON l.OrderHeaderID = h.OrderHeaderID
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK)
ON c.OrderLineID = l.OrderLineID
AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK)
ON caf.CurrAccTypeCode = h.CurrAccTypeCode
AND caf.CurrAccCode = h.CurrAccCode
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK)
ON cad.CurrAccTypeCode = h.CurrAccTypeCode
AND cad.CurrAccCode = h.CurrAccCode
AND cad.LangCode = 'TR'
OUTER APPLY (
SELECT TOP 1 Rate
FROM dbo.AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = 'USD'
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
ORDER BY Date DESC
) usd
OUTER APPLY (
SELECT TOP 1 Rate
FROM dbo.AllExchangeRates WITH(NOLOCK)
WHERE CurrencyCode = h.DocCurrencyCode
AND RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
ORDER BY Date DESC
) cur
WHERE ISNULL(h.IsCancelOrder, 0) = 0
AND ISNULL(h.IsClosed, 0) = 0
AND h.OrderTypeCode = 1
AND h.ProcessCode = 'WS'
AND ISNULL(l.IsClosed, 0) = 0
AND l.ItemTypeCode = 1
AND ISNULL(l.Qty1, 0) > 0
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
AND (
l.ItemCode LIKE 'S%'
OR l.ItemCode LIKE 'O%'
OR l.ItemCode LIKE 'N%'
OR l.ItemCode LIKE 'X%'
OR l.ItemCode LIKE 'I%'
OR l.ItemCode LIKE 'A%'
)
)
SELECT TOP (@p1)
ProductCode,
ColorCode,
YakaKodu,
MarketKey,
CurrAccCode,
CustomerName,
OrderQty = SUM(Qty),
OrderUSD = SUM(AmountUSD),
OrderCount = COUNT(DISTINCT OrderHeaderID),
LineCount = COUNT(*),
OverdueQty = SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END)
FROM OpenOrderLines
GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName
ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC
`, limit*4)
if err != nil {
return nil, err
}
defer rows.Close()
type detail struct {
productCode string
colorCode string
yakaKodu string
marketKey string
customerCode string
customerName string
orderQty float64
orderUSD float64
orderCount int
lineCount int
overdueQty float64
}
details := make([]detail, 0, limit*2)
productCodes := make([]string, 0, limit)
stockKeys := make([]string, 0, limit)
seenProducts := map[string]bool{}
seenStockKeys := map[string]bool{}
for rows.Next() {
var d detail
if err := rows.Scan(&d.productCode, &d.colorCode, &d.yakaKodu, &d.marketKey, &d.customerCode, &d.customerName, &d.orderQty, &d.orderUSD, &d.orderCount, &d.lineCount, &d.overdueQty); err != nil {
return nil, err
}
details = append(details, d)
if !seenProducts[d.productCode] {
seenProducts[d.productCode] = true
productCodes = append(productCodes, d.productCode)
}
key := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu)
if !seenStockKeys[key] {
seenStockKeys[key] = true
stockKeys = append(stockKeys, key)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
if err != nil {
return nil, err
}
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
if err != nil {
return nil, err
}
grouped := map[string]*models.ProductPerformanceOrderGroupRow{}
productSets := map[string]map[string]bool{}
marketSets := map[string]map[string]bool{}
customerSets := map[string]map[string]bool{}
stockVariantSets := map[string]map[string]bool{}
for _, d := range details {
groupKey := strings.TrimSpace(d.marketKey)
customerCode := ""
customerName := ""
if mode == "customer" {
groupKey = strings.TrimSpace(d.customerCode)
customerCode = strings.TrimSpace(d.customerCode)
customerName = strings.TrimSpace(d.customerName)
}
if groupKey == "" {
groupKey = "-"
}
row := grouped[groupKey]
if row == nil {
row = &models.ProductPerformanceOrderGroupRow{
Breakdown: mode,
GroupKey: groupKey,
MarketKey: strings.TrimSpace(d.marketKey),
CustomerCode: customerCode,
CustomerName: customerName,
}
grouped[groupKey] = row
productSets[groupKey] = map[string]bool{}
marketSets[groupKey] = map[string]bool{}
customerSets[groupKey] = map[string]bool{}
stockVariantSets[groupKey] = map[string]bool{}
}
price := priceByProduct[d.productCode]
variantKey := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu)
row.OrderQty += d.orderQty
row.OrderUSD += d.orderUSD
row.BaseCostUSD += d.orderQty * price.base
row.CostAmountUSD += d.orderQty * price.cost
if !stockVariantSets[groupKey][variantKey] {
row.StockQty += stockByKey[variantKey]
stockVariantSets[groupKey][variantKey] = true
}
row.OrderCount += d.orderCount
row.LineCount += d.lineCount
row.OverdueQty += d.overdueQty
productSets[groupKey][d.productCode] = true
if strings.TrimSpace(d.marketKey) != "" {
marketSets[groupKey][strings.TrimSpace(d.marketKey)] = true
}
if strings.TrimSpace(d.customerCode) != "" {
customerSets[groupKey][strings.TrimSpace(d.customerCode)] = true
}
}
out := make([]models.ProductPerformanceOrderGroupRow, 0, len(grouped))
for key, row := range grouped {
row.ProductCount = len(productSets[key])
row.MarketCount = len(marketSets[key])
row.CustomerCount = len(customerSets[key])
row.NetStockAfterOrder = row.StockQty - row.OrderQty
if row.OrderQty != 0 {
row.AvgOrderPriceUSD = row.OrderUSD / row.OrderQty
}
row.ExpectedProfitBaseUSD = row.OrderUSD - row.BaseCostUSD
row.ExpectedProfitCostUSD = row.OrderUSD - row.CostAmountUSD
if row.OrderUSD != 0 {
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
}
row.PerformanceBucket, row.Recommendation = productPerformanceOrderGroupRecommendation(*row)
out = append(out, *row)
}
sort.Slice(out, func(i, j int) bool {
if out[i].ExpectedProfitCostUSD < 0 && out[j].ExpectedProfitCostUSD >= 0 {
return true
}
if out[i].ExpectedProfitCostUSD >= 0 && out[j].ExpectedProfitCostUSD < 0 {
return false
}
return out[i].OrderUSD > out[j].OrderUSD
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderProductCustomerRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
}
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 1000 {
limit = 500
}
rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS (
SELECT
h.OrderHeaderID,
h.CurrAccCode,
CustomerName = ISNULL(cad.CurrAccDescription, ''),
ProductCode = LTRIM(RTRIM(l.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))),
ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))),
MarketKey = dbo.HG_Temizlik(ISNULL((
SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK)
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
AND cdCurrAccAttributeDesc.LangCode = 'TR'
), SPACE(0))),
Qty = ISNULL(l.Qty1, 0),
AmountUSD = CASE
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
ELSE 0
END,
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date)
FROM dbo.trOrderHeader h WITH(NOLOCK)
INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR'
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur
WHERE ISNULL(h.IsCancelOrder, 0) = 0
AND ISNULL(h.IsClosed, 0) = 0
AND h.OrderTypeCode = 1
AND h.ProcessCode = 'WS'
AND ISNULL(l.IsClosed, 0) = 0
AND l.ItemTypeCode = 1
AND ISNULL(l.Qty1, 0) > 0
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
AND (l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%')
)
SELECT TOP (@p1)
ProductCode, ColorCode, YakaKodu, MAX(ItemDescription), MarketKey, CurrAccCode, CustomerName,
SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END,
COUNT(DISTINCT OrderHeaderID), COUNT(*), SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END)
FROM OpenOrderLines
GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName
ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceOrderProductCustomerRow, 0, limit)
productCodes := make([]string, 0, limit)
stockKeys := make([]string, 0, limit)
seenProducts := map[string]bool{}
seenStockKeys := map[string]bool{}
for rows.Next() {
var r models.ProductPerformanceOrderProductCustomerRow
if err := rows.Scan(&r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.OrderCount, &r.LineCount, &r.OverdueQty); err != nil {
return nil, err
}
out = append(out, r)
if !seenProducts[r.ProductCode] {
seenProducts[r.ProductCode] = true
productCodes = append(productCodes, r.ProductCode)
}
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
if !seenStockKeys[key] {
seenStockKeys[key] = true
stockKeys = append(stockKeys, key)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
if err != nil {
return nil, err
}
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
if err != nil {
return nil, err
}
for i := range out {
row := &out[i]
price := priceByProduct[row.ProductCode]
row.CostPriceUSD = price.cost
row.BasePriceUSD = price.base
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
row.NetStockAfterOrder = row.StockQty - row.OrderQty
row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD
row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD
if row.OrderUSD != 0 {
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
}
row.PerformanceBucket, row.Recommendation = productPerformanceOrderProductCustomerRecommendation(*row)
}
return out, nil
}
func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderMarketDetailRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
}
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 1500 {
limit = 800
}
rows, err := db.MssqlDB.QueryContext(ctx, `
WITH OpenOrderLines AS (
SELECT
MarketKey = dbo.HG_Temizlik(ISNULL((SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR'), SPACE(0))),
h.CurrAccCode,
CustomerName = ISNULL(cad.CurrAccDescription, ''),
h.OrderNumber,
OrderDate = CAST(h.OrderDate AS date),
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date),
ProductCode = LTRIM(RTRIM(l.ItemCode)),
ColorCode = LTRIM(RTRIM(ISNULL(l.ColorCode, ''))),
YakaKodu = LTRIM(RTRIM(ISNULL(l.ItemDim2Code, ''))),
ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))),
Qty = ISNULL(l.Qty1, 0),
AmountUSD = CASE
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
ELSE 0
END
FROM dbo.trOrderHeader h WITH(NOLOCK)
INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR'
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur
WHERE ISNULL(h.IsCancelOrder, 0) = 0
AND ISNULL(h.IsClosed, 0) = 0
AND h.OrderTypeCode = 1
AND h.ProcessCode = 'WS'
AND ISNULL(l.IsClosed, 0) = 0
AND l.ItemTypeCode = 1
AND ISNULL(l.Qty1, 0) > 0
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
AND (l.ItemCode LIKE 'S%' OR l.ItemCode LIKE 'O%' OR l.ItemCode LIKE 'N%' OR l.ItemCode LIKE 'X%' OR l.ItemCode LIKE 'I%' OR l.ItemCode LIKE 'A%')
)
SELECT TOP (@p1)
MarketKey, CurrAccCode, CustomerName, OrderNumber, CONVERT(varchar, OrderDate, 23), CONVERT(varchar, DueDate, 23),
ProductCode, ColorCode, YakaKodu, MAX(ItemDescription),
SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END,
CASE WHEN MIN(DueDate) < CAST(GETDATE() AS date) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END
FROM OpenOrderLines
GROUP BY MarketKey, CurrAccCode, CustomerName, OrderNumber, OrderDate, DueDate, ProductCode, ColorCode, YakaKodu
ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DESC
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceOrderMarketDetailRow, 0, limit)
productCodes := make([]string, 0, limit)
stockKeys := make([]string, 0, limit)
seenProducts := map[string]bool{}
seenStockKeys := map[string]bool{}
for rows.Next() {
var r models.ProductPerformanceOrderMarketDetailRow
if err := rows.Scan(&r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderNumber, &r.OrderDate, &r.DueDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.IsOverdue); err != nil {
return nil, err
}
out = append(out, r)
if !seenProducts[r.ProductCode] {
seenProducts[r.ProductCode] = true
productCodes = append(productCodes, r.ProductCode)
}
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
if !seenStockKeys[key] {
seenStockKeys[key] = true
stockKeys = append(stockKeys, key)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
if err != nil {
return nil, err
}
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
if err != nil {
return nil, err
}
for i := range out {
row := &out[i]
price := priceByProduct[row.ProductCode]
row.CostPriceUSD = price.cost
row.BasePriceUSD = price.base
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
row.NetStockAfterOrder = row.StockQty - row.OrderQty
row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD
row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD
if row.OrderUSD != 0 {
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
}
row.PerformanceBucket, row.Recommendation = productPerformanceOrderMarketDetailRecommendation(*row)
}
return out, nil
}
func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := pg.QueryContext(ctx, `
SELECT
market_key,
MAX(kategori) AS kategori,
MAX(seri) AS seri,
MAX(yas_grubu) AS yas_grubu,
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,
COUNT(DISTINCT product_code) AS product_count,
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN') AS star_count,
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI') AS stock_risk_count,
COALESCE(SUM(stock_qty),0) AS stock_qty,
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_value_usd,
COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_stock_cost_value_usd,
COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d,
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
COALESCE(AVG(NULLIF(gross_margin_90d,0)),0) AS avg_gross_margin_90d,
COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
GROUP BY market_key
ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceMarketRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceMarketRow
if err := rows.Scan(
&r.MarketKey, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
&r.ProductCount, &r.StarCount, &r.StockRiskCount, &r.StockQty,
&r.StockCostValueUSD, &r.RiskStockCostValueUSD, &r.SalesQty90,
&r.SalesUSD90, &r.GrossProfitUSD90, &r.AvgGrossMargin90, &r.AvgStockDays90,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceCountryRow, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := pg.QueryContext(ctx, `
SELECT
customer_country,
customer_segment,
market_key,
MAX(kategori) AS kategori,
MAX(seri) AS seri,
COUNT(DISTINCT product_code) AS product_count,
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
CASE
WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
END AS avg_price_usd_90d,
COALESCE(SUM(customer_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS customer_count_90d,
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d,
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d
FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days'
GROUP BY customer_country, customer_segment, market_key
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceCountryRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceCountryRow
if err := rows.Scan(
&r.Country, &r.CustomerSegment, &r.MarketKey, &r.Kategori, &r.Seri,
&r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90,
&r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty365, &r.SalesUSD365,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceCustomerRow, error) {
if err := EnsureProductPerformanceTables(pg); err != nil {
return nil, err
}
if limit <= 0 || limit > 500 {
limit = 100
}
mode := strings.ToLower(strings.TrimSpace(breakdown))
if mode == "" {
mode = "market_customer"
}
selectMarket := "''"
selectCountry := "''"
selectSegment := "''"
groupCols := []string{"customer_code", "customer_name"}
switch mode {
case "country_customer":
selectCountry = "customer_country"
selectSegment = "customer_segment"
groupCols = append(groupCols, "customer_country", "customer_segment")
case "market_country_customer":
selectMarket = "market_key"
selectCountry = "customer_country"
selectSegment = "customer_segment"
groupCols = append(groupCols, "market_key", "customer_country", "customer_segment")
default:
mode = "market_customer"
selectMarket = "market_key"
groupCols = append(groupCols, "market_key")
}
query := fmt.Sprintf(`
SELECT
$2::text AS breakdown,
%s AS market_key,
%s AS country,
%s AS customer_segment,
customer_code,
customer_name,
COUNT(DISTINCT product_code) AS product_count,
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
CASE
WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
END AS avg_price_usd_90d,
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d,
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d,
COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date
FROM mk_product_performance_sales_daily
WHERE sales_date >= current_date - INTERVAL '364 days'
AND COALESCE(customer_code,'') <> ''
GROUP BY %s
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
LIMIT $1
`, selectMarket, selectCountry, selectSegment, strings.Join(groupCols, ", "))
rows, err := pg.QueryContext(ctx, query, limit, mode)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceCustomerRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceCustomerRow
if err := rows.Scan(
&r.Breakdown, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
&r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.InvoiceCount90,
&r.SalesQty365, &r.SalesUSD365, &r.LastSaleDate,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := pg.QueryContext(ctx, `
SELECT
to_char(sales_date,'YYYY-MM-DD') AS sales_date,
last_ref_number,
market_key,
customer_country,
customer_segment,
customer_code,
customer_name,
sales_qty,
sales_usd,
avg_price_usd
FROM mk_product_performance_sales_daily
WHERE product_code=$1
AND color_code=$2
AND yaka_kodu=$3
ORDER BY sales_date DESC, sales_usd DESC
LIMIT $4
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceSalesDetailRow, 0, limit)
for rows.Next() {
var r models.ProductPerformanceSalesDetailRow
if err := rows.Scan(&r.SalesDate, &r.RefNumber, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesUSD, &r.AvgPriceUSD); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCode, yakaKodu string) ([]models.ProductPerformanceStockSizeRow, error) {
if db.MssqlDB == nil {
return nil, fmt.Errorf("mssql db nil")
}
rows, err := db.MssqlDB.QueryContext(ctx, `
SELECT
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
StockQty = SUM(S.InventoryQty1)
FROM StockWithCost S WITH(NOLOCK)
WHERE S.ItemTypeCode = 1
AND LTRIM(RTRIM(S.ItemCode)) = @p1
AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2
AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3
GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))
HAVING SUM(S.InventoryQty1) <> 0
ORDER BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu))
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.ProductPerformanceStockSizeRow, 0, 32)
for rows.Next() {
var r models.ProductPerformanceStockSizeRow
if err := rows.Scan(&r.SizeCode, &r.StockQty); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
parts := make([]string, 0, 6)
args := make([]any, 0, 6)
add := func(cond string, value any) {
args = append(args, value)
parts = append(parts, fmt.Sprintf(cond, len(args)))
}
if q := strings.TrimSpace(f.Search); q != "" {
args = append(args, q)
idx := len(args)
parts = append(parts, fmt.Sprintf(" AND (product_code ILIKE '%%' || $%d || '%%' OR item_description ILIKE '%%' || $%d || '%%')", idx, idx))
}
if v := strings.TrimSpace(f.ProductCode); v != "" {
add(" AND product_code = $%d", v)
}
if v := strings.TrimSpace(f.MarketKey); v != "" {
add(" AND market_key = $%d", v)
}
if v := strings.TrimSpace(f.Kategori); v != "" {
add(" AND kategori = $%d", v)
}
if v := strings.TrimSpace(f.Seri); v != "" {
args = append(args, v)
idx := len(args)
parts = append(parts, fmt.Sprintf(" AND (seri = $%d OR urun_ana_grubu = $%d)", idx, idx))
}
if v := strings.TrimSpace(f.Bucket); v != "" {
add(" AND performance_bucket = $%d", v)
}
return strings.Join(parts, ""), args
}
func productPerformanceOrderBy(sortBy string, desc bool) string {
allowed := map[string]string{
"product_code": "product_code",
"color_code": "color_code",
"yaka_kodu": "yaka_kodu",
"item_description": "item_description",
"kategori": "kategori",
"urun_ilk_grubu": "urun_ilk_grubu",
"askili_yan": "askili_yan",
"urun_ana_grubu": "urun_ana_grubu",
"urun_alt_grubu": "urun_alt_grubu",
"market_key": "market_key",
"stock_qty": "stock_qty",
"sales_qty_90d": "sales_qty_90d",
"sales_qty_180d": "sales_qty_180d",
"sales_usd_90d": "sales_usd_90d",
"sales_usd_180d": "sales_usd_180d",
"stock_days_90d": "stock_days_90d",
"stock_days_180d": "stock_days_180d",
"avg_price_usd_90d": "avg_price_usd_90d",
"avg_price_usd_180d": "avg_price_usd_180d",
"base_price_usd": "base_price_usd",
"cost_price_usd": "cost_price_usd",
"unit_profit_base_90d": "unit_profit_base_90d",
"unit_profit_cost_90d": "unit_profit_cost_90d",
"unit_profit_base_180d": "unit_profit_base_180d",
"unit_profit_cost_180d": "unit_profit_cost_180d",
"gross_margin_90d": "gross_margin_90d",
"gross_margin_180d": "gross_margin_180d",
"market_count_90d": "market_count_90d",
"customer_count_90d": "customer_count_90d",
"sales_index_90d": "sales_index_90d",
"performance_score": "performance_score",
"performance_bucket": "performance_bucket",
"last_sale_date": "last_sale_date",
}
col := allowed[strings.TrimSpace(sortBy)]
if col == "" {
col = "performance_score"
desc = true
}
dir := "ASC"
if desc {
dir = "DESC"
}
return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC"
}
type productPerformancePriceInfo struct {
cost float64
base float64
}
func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) string {
return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu)
}
func productPerformancePriceLookup(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]productPerformancePriceInfo, error) {
out := make(map[string]productPerformancePriceInfo, len(productCodes))
if len(productCodes) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT product_code, COALESCE(cost_price_usd,0), COALESCE(base_price_usd,0)
FROM mk_product_performance_price_dim
WHERE product_code = ANY($1)
`, pq.Array(productCodes))
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var productCode string
var p productPerformancePriceInfo
if err := rows.Scan(&productCode, &p.cost, &p.base); err != nil {
return nil, err
}
out[productCode] = p
}
return out, rows.Err()
}
func productPerformanceStockLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]float64, error) {
out := make(map[string]float64, len(variantKeys))
if len(variantKeys) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
WITH Latest AS (
SELECT MAX(stock_date) AS stock_date
FROM mk_product_performance_stock_daily
)
SELECT
product_code || '|' || color_code || '|' || yaka_kodu AS variant_key,
COALESCE(SUM(stock_qty),0) AS stock_qty
FROM mk_product_performance_stock_daily
WHERE stock_date = (SELECT stock_date FROM Latest)
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
GROUP BY product_code, color_code, yaka_kodu
`, pq.Array(variantKeys))
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key string
var stockQty float64
if err := rows.Scan(&key, &stockQty); err != nil {
return nil, err
}
out[key] = stockQty
}
return out, rows.Err()
}
func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) {
switch {
case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Açık sipariş stoktan büyük. Karlı talep var; üretim/satın alma önceliği ver."
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Açık sipariş çıplak maliyete göre zarar yazıyor. Fiyat/maliyet kontrol edilmeli."
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder >= 0:
return "YILDIZ_URUN", "Açık sipariş karlı ve stok karşılıyor. Teslimat korunmalı."
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0:
return "FIYAT_FIRSATI", "Karlı talep var ama stok yetersiz. Üretim planına alınmalı."
case row.OverdueQty > 0:
return "TAKIP", "Termin gecikmesi olan açık sipariş var. Operasyon takibi gerekli."
default:
return "TAKIP", "Sipariş, stok ve fiyat düzenli izlenmeli."
}
}
func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Çıplak maliyete göre zarar yazan açık sipariş var. Fiyat/maliyet acil kontrol edilmeli."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Taban maliyete göre brüt zarar var. Satış fiyatı veya iskonto kontrol edilmeli."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Karlı açık talep var ama stok yetersiz. Üretim/satın alma önceliği ver."
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
return "YILDIZ_URUN", "Piyasa/müşteri açık siparişleri karlı. Teslimat ve stok korunmalı."
case row.OverdueQty > 0:
return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli."
default:
return "TAKIP", "Sipariş karlılığı ve stok yeterliliği izlenmeli."
}
}
func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi çıplak maliyete göre zarar yazıyor."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi taban maliyete göre brüt zarar yazıyor."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Müşteride karlı talep var ama stok yetersiz."
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
return "YILDIZ_URUN", "Müşteri bazında karlı açık talep var."
case row.OverdueQty > 0:
return "TAKIP", "Bu müşteri/ürün kırılımında geciken açık sipariş var."
default:
return "TAKIP", "Müşteri talebi, fiyat ve stok birlikte izlenmeli."
}
}
func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) {
switch {
case row.ExpectedProfitCostUSD < 0:
return "FIYAT_BASKISI", "Sipariş satırı çıplak maliyete göre zarar yazıyor."
case row.ExpectedProfitBaseUSD < 0:
return "FIYAT_BASKISI", "Sipariş satırı taban maliyete göre brüt zarar yazıyor."
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
return "STOKSUZ_TALEP", "Karlı sipariş var ama mevcut stok siparişi karşılamıyor."
case row.IsOverdue:
return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli."
case row.ExpectedMarginCost >= 0.25:
return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı."
default:
return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli."
}
}
func dateOnly(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
type productPerformanceSalesDaily struct {
SalesDate time.Time
ProductCode string
ColorCode string
YakaKodu string
ItemDescription string
Kategori string
Seri string
YasGrubu string
AskiliYan string
UrunIlkGrubu string
UrunAnaGrubu string
UrunAltGrubu string
MarketKey string
ChannelCode string
CustomerCountry string
CustomerSegment string
CustomerCode string
CustomerName string
SalesQty float64
SalesTL float64
SalesUSD float64
AvgPriceUSD float64
InvoiceLineCount int
InvoiceCount int
CustomerCount int
LastRefNumber string
}
type productPerformanceStockDaily struct {
StockDate time.Time
ProductCode string
ColorCode string
YakaKodu string
StockQty float64
InQty float64
OutQty float64
KpiInQty float64
KpiOutQty float64
SalesMovementQty float64
ProductionInQty float64
PurchaseInQty float64
ConsumptionOutQty float64
CountDiffQty float64
}