964 lines
37 KiB
Go
964 lines
37 KiB
Go
package queries
|
|
|
|
import (
|
|
"bssapp-backend/db"
|
|
"bssapp-backend/models"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ProductPerformanceFilters struct {
|
|
Search string
|
|
ProductCode string
|
|
MarketKey string
|
|
Kategori string
|
|
Seri string
|
|
Bucket string
|
|
Limit int
|
|
Page int
|
|
}
|
|
|
|
type ProductPerformanceRefreshRequest struct {
|
|
Mode string
|
|
Stage string
|
|
ResumeAfter int
|
|
SkipDelete bool
|
|
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_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_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_price_usd_90d 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_margin_90d NUMERIC(18,6) 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 ''`,
|
|
}
|
|
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)
|
|
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"))
|
|
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")
|
|
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 {
|
|
return err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] delete sales cache done")
|
|
log.Printf("[ProductPerformanceRefresh] sales refresh start")
|
|
rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate)
|
|
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)
|
|
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); 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 refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time) (int, error) {
|
|
log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate)
|
|
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) (int, error) {
|
|
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate)
|
|
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) (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")
|
|
if _, err := deleteTx.ExecContext(ctx, `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2`, startDate, endDate); 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", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockSQL(), startDate, endDate)
|
|
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) error {
|
|
log.Printf("[ProductPerformanceRefresh] price mssql query start")
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL())
|
|
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)
|
|
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_365d, sales_qty_730d,
|
|
sales_usd_30d, sales_usd_90d, sales_usd_365d, avg_daily_sales_90d,
|
|
stock_days_90d, avg_price_usd_90d, cost_price_usd, base_price_usd, base_price_try,
|
|
gross_profit_usd_90d, gross_margin_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 performance_score DESC, sales_qty_90d DESC, product_code
|
|
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.SalesQty365, &r.SalesQty730,
|
|
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD365, &r.AvgDailySales90,
|
|
&r.StockDays90, &r.AvgPriceUSD90, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
|
|
&r.GrossProfitUSD90, &r.GrossMargin90, &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, `
|
|
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(to_char(MAX(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)
|
|
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.UpdatedAt)
|
|
return s, err
|
|
}
|
|
|
|
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 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 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
|
|
}
|