4167 lines
162 KiB
Go
4167 lines
162 KiB
Go
package queries
|
||
|
||
import (
|
||
"bssapp-backend/db"
|
||
"bssapp-backend/models"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"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 productPerformanceStockQuery() string {
|
||
return `
|
||
;WITH ActiveWarehouses AS (
|
||
SELECT WarehouseCode
|
||
FROM (VALUES
|
||
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'),
|
||
('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),
|
||
('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36')
|
||
) W(WarehouseCode)
|
||
),
|
||
Raw AS (
|
||
SELECT
|
||
ProductCode = LTRIM(RTRIM(S.ItemCode)),
|
||
ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))),
|
||
YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))),
|
||
MovementDate = CAST(S.DocumentDate AS date),
|
||
ProcessCode = CASE
|
||
WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode))
|
||
ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, '')))
|
||
END,
|
||
InQty = SUM(S.In_Qty1),
|
||
OutQty = SUM(S.Out_Qty1),
|
||
NetQty = SUM(S.In_Qty1 - S.Out_Qty1)
|
||
FROM trStock S WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
||
WHERE S.ItemTypeCode = 1
|
||
AND (
|
||
LEN(LTRIM(RTRIM(S.ItemCode))) = 13
|
||
AND (
|
||
S.ItemCode LIKE 'S%'
|
||
OR S.ItemCode LIKE 'O%'
|
||
OR S.ItemCode LIKE 'N%'
|
||
OR S.ItemCode LIKE 'X%'
|
||
OR S.ItemCode LIKE 'I%'
|
||
OR S.ItemCode LIKE 'A%'
|
||
)
|
||
)
|
||
AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%')
|
||
AND S.DocumentDate < DATEADD(DAY, 1, @p2)
|
||
GROUP BY
|
||
S.ItemCode,
|
||
S.ColorCode,
|
||
S.ItemDim2Code,
|
||
CAST(S.DocumentDate AS date),
|
||
CASE
|
||
WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode))
|
||
ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, '')))
|
||
END
|
||
),
|
||
Opening AS (
|
||
SELECT
|
||
StockDate = @p1,
|
||
ProductCode,
|
||
ColorCode,
|
||
YakaKodu,
|
||
NetQty = SUM(NetQty),
|
||
InQty = CAST(0 AS decimal(18,4)),
|
||
OutQty = CAST(0 AS decimal(18,4)),
|
||
KpiInQty = CAST(0 AS decimal(18,4)),
|
||
KpiOutQty = CAST(0 AS decimal(18,4)),
|
||
SalesMovementQty = CAST(0 AS decimal(18,4)),
|
||
ProductionInQty = CAST(0 AS decimal(18,4)),
|
||
PurchaseInQty = CAST(0 AS decimal(18,4)),
|
||
ConsumptionOutQty = CAST(0 AS decimal(18,4)),
|
||
CountDiffQty = CAST(0 AS decimal(18,4))
|
||
FROM Raw
|
||
WHERE MovementDate < @p1
|
||
GROUP BY ProductCode, ColorCode, YakaKodu
|
||
),
|
||
Daily AS (
|
||
SELECT
|
||
StockDate = MovementDate,
|
||
ProductCode,
|
||
ColorCode,
|
||
YakaKodu,
|
||
NetQty = SUM(NetQty),
|
||
InQty = SUM(InQty),
|
||
OutQty = SUM(OutQty),
|
||
KpiInQty = SUM(CASE WHEN ProcessCode IN ('OP','BP','CI') THEN InQty ELSE 0 END),
|
||
KpiOutQty = SUM(CASE WHEN ProcessCode IN ('R','WS','OC','CO') THEN OutQty ELSE 0 END),
|
||
SalesMovementQty = SUM(CASE WHEN ProcessCode IN ('R','WS') THEN OutQty ELSE 0 END),
|
||
ProductionInQty = SUM(CASE WHEN ProcessCode = 'OP' THEN InQty ELSE 0 END),
|
||
PurchaseInQty = SUM(CASE WHEN ProcessCode = 'BP' THEN InQty ELSE 0 END),
|
||
ConsumptionOutQty = SUM(CASE WHEN ProcessCode = 'OC' THEN OutQty ELSE 0 END),
|
||
CountDiffQty = SUM(CASE WHEN ProcessCode IN ('CO','CI') THEN NetQty ELSE 0 END)
|
||
FROM Raw
|
||
WHERE MovementDate BETWEEN @p1 AND @p2
|
||
GROUP BY MovementDate, ProductCode, ColorCode, YakaKodu
|
||
),
|
||
Series AS (
|
||
SELECT * FROM Opening
|
||
UNION ALL
|
||
SELECT * FROM Daily
|
||
),
|
||
Collapsed AS (
|
||
SELECT
|
||
StockDate,
|
||
ProductCode,
|
||
ColorCode,
|
||
YakaKodu,
|
||
NetQty = SUM(NetQty),
|
||
InQty = SUM(InQty),
|
||
OutQty = SUM(OutQty),
|
||
KpiInQty = SUM(KpiInQty),
|
||
KpiOutQty = SUM(KpiOutQty),
|
||
SalesMovementQty = SUM(SalesMovementQty),
|
||
ProductionInQty = SUM(ProductionInQty),
|
||
PurchaseInQty = SUM(PurchaseInQty),
|
||
ConsumptionOutQty = SUM(ConsumptionOutQty),
|
||
CountDiffQty = SUM(CountDiffQty)
|
||
FROM Series
|
||
GROUP BY StockDate, ProductCode, ColorCode, YakaKodu
|
||
),
|
||
Running AS (
|
||
SELECT
|
||
StockDate,
|
||
ProductCode,
|
||
ColorCode,
|
||
YakaKodu,
|
||
StockQty = SUM(NetQty) OVER (
|
||
PARTITION BY ProductCode, ColorCode, YakaKodu
|
||
ORDER BY StockDate
|
||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
||
),
|
||
InQty,
|
||
OutQty,
|
||
KpiInQty,
|
||
KpiOutQty,
|
||
SalesMovementQty,
|
||
ProductionInQty,
|
||
PurchaseInQty,
|
||
ConsumptionOutQty,
|
||
CountDiffQty
|
||
FROM Collapsed
|
||
),
|
||
CurrentStock AS (
|
||
SELECT
|
||
ProductCode = LTRIM(RTRIM(S.ItemCode)),
|
||
ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))),
|
||
YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))),
|
||
InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1)
|
||
FROM trStock S WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
||
WHERE S.ItemTypeCode = 1
|
||
AND (
|
||
LEN(LTRIM(RTRIM(S.ItemCode))) = 13
|
||
AND (
|
||
S.ItemCode LIKE 'S%'
|
||
OR S.ItemCode LIKE 'O%'
|
||
OR S.ItemCode LIKE 'N%'
|
||
OR S.ItemCode LIKE 'X%'
|
||
OR S.ItemCode LIKE 'I%'
|
||
OR S.ItemCode LIKE 'A%'
|
||
)
|
||
)
|
||
AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%')
|
||
AND S.DocumentDate < DATEADD(DAY, 1, @p2)
|
||
GROUP BY S.ItemCode, S.ColorCode, S.ItemDim2Code
|
||
),
|
||
CurrentPick AS (
|
||
SELECT
|
||
ProductCode = LTRIM(RTRIM(P.ItemCode)),
|
||
ColorCode = LTRIM(RTRIM(ISNULL(P.ColorCode, ''))),
|
||
YakaKodu = LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))),
|
||
PickingQty1 = SUM(P.Qty1)
|
||
FROM PickingStates P WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode))
|
||
WHERE P.ItemTypeCode = 1
|
||
AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13
|
||
AND (@p3 = '' OR P.ItemCode LIKE @p3 + '%')
|
||
GROUP BY P.ItemCode, P.ColorCode, P.ItemDim2Code
|
||
),
|
||
CurrentReserve AS (
|
||
SELECT
|
||
ProductCode = LTRIM(RTRIM(R.ItemCode)),
|
||
ColorCode = LTRIM(RTRIM(ISNULL(R.ColorCode, ''))),
|
||
YakaKodu = LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))),
|
||
ReserveQty1 = SUM(R.Qty1)
|
||
FROM ReserveStates R WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode))
|
||
WHERE R.ItemTypeCode = 1
|
||
AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13
|
||
AND (@p3 = '' OR R.ItemCode LIKE @p3 + '%')
|
||
GROUP BY R.ItemCode, R.ColorCode, R.ItemDim2Code
|
||
),
|
||
CurrentDisp AS (
|
||
SELECT
|
||
ProductCode = LTRIM(RTRIM(D.ItemCode)),
|
||
ColorCode = LTRIM(RTRIM(ISNULL(D.ColorCode, ''))),
|
||
YakaKodu = LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))),
|
||
DispOrderQty1 = SUM(D.Qty1)
|
||
FROM DispOrderStates D WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode))
|
||
WHERE D.ItemTypeCode = 1
|
||
AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13
|
||
AND (@p3 = '' OR D.ItemCode LIKE @p3 + '%')
|
||
GROUP BY D.ItemCode, D.ColorCode, D.ItemDim2Code
|
||
),
|
||
CurrentAvailable AS (
|
||
SELECT
|
||
StockDate = @p2,
|
||
S.ProductCode,
|
||
S.ColorCode,
|
||
S.YakaKodu,
|
||
StockQty = CAST(ROUND(
|
||
ISNULL(S.InventoryQty1, 0)
|
||
- ISNULL(PK.PickingQty1, 0)
|
||
- ISNULL(RS.ReserveQty1, 0)
|
||
- ISNULL(DP.DispOrderQty1, 0),
|
||
2
|
||
) AS decimal(18,4)),
|
||
InQty = CAST(0 AS decimal(18,4)),
|
||
OutQty = CAST(0 AS decimal(18,4)),
|
||
KpiInQty = CAST(0 AS decimal(18,4)),
|
||
KpiOutQty = CAST(0 AS decimal(18,4)),
|
||
SalesMovementQty = CAST(0 AS decimal(18,4)),
|
||
ProductionInQty = CAST(0 AS decimal(18,4)),
|
||
PurchaseInQty = CAST(0 AS decimal(18,4)),
|
||
ConsumptionOutQty = CAST(0 AS decimal(18,4)),
|
||
CountDiffQty = CAST(0 AS decimal(18,4))
|
||
FROM CurrentStock S
|
||
LEFT JOIN CurrentPick PK
|
||
ON PK.ProductCode = S.ProductCode AND PK.ColorCode = S.ColorCode AND PK.YakaKodu = S.YakaKodu
|
||
LEFT JOIN CurrentReserve RS
|
||
ON RS.ProductCode = S.ProductCode AND RS.ColorCode = S.ColorCode AND RS.YakaKodu = S.YakaKodu
|
||
LEFT JOIN CurrentDisp DP
|
||
ON DP.ProductCode = S.ProductCode AND DP.ColorCode = S.ColorCode AND DP.YakaKodu = S.YakaKodu
|
||
)
|
||
SELECT
|
||
StockDate,
|
||
ProductCode,
|
||
ColorCode,
|
||
YakaKodu,
|
||
StockQty,
|
||
InQty,
|
||
OutQty,
|
||
KpiInQty,
|
||
KpiOutQty,
|
||
SalesMovementQty,
|
||
ProductionInQty,
|
||
PurchaseInQty,
|
||
ConsumptionOutQty,
|
||
CountDiffQty
|
||
FROM (
|
||
SELECT * FROM Running WHERE StockDate BETWEEN @p1 AND DATEADD(DAY, -1, @p2)
|
||
UNION ALL
|
||
SELECT * FROM CurrentAvailable
|
||
) X
|
||
ORDER BY StockDate, ProductCode, ColorCode, YakaKodu
|
||
`
|
||
}
|
||
|
||
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, productPerformanceStockQuery(), 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, productPerformanceStockQuery(), startDate, endDate, productPrefix)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres chunk upsert 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 upserted_rows=%d scanned_rows=%d", count, seen)
|
||
if err := tx.Commit(); err != nil {
|
||
return count, err
|
||
}
|
||
log.Printf("[ProductPerformanceRefresh] stock chunk commit done upserted_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 upserted_rows=%d scanned_rows=%d", count, seen)
|
||
if err := tx.Commit(); err != nil {
|
||
return count, err
|
||
}
|
||
log.Printf("[ProductPerformanceRefresh] stock final chunk commit done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
|
||
} else {
|
||
_ = tx.Rollback()
|
||
}
|
||
log.Printf("[ProductPerformanceRefresh] stock refresh done upserted_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 prepareProductPerformanceStockCopy(ctx context.Context, tx *sql.Tx) (*sql.Stmt, error) {
|
||
return tx.PrepareContext(ctx, pq.CopyIn(
|
||
"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",
|
||
))
|
||
}
|
||
|
||
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 = 100
|
||
} else if limit > 50000 {
|
||
limit = 50000
|
||
}
|
||
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)
|
||
),
|
||
VariantStock AS (
|
||
SELECT
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
MAX(stock_qty) AS stock_qty,
|
||
MAX(cost_price_usd) AS cost_price_usd,
|
||
BOOL_OR(performance_bucket IN ('STOK_RISKI','TAKIP')) AS has_risk_bucket,
|
||
SUM(COALESCE(sales_qty_90d,0)) AS sales_qty_90d
|
||
FROM KPI
|
||
GROUP BY product_code, color_code, yaka_kodu
|
||
),
|
||
StockTotals AS (
|
||
SELECT
|
||
COALESCE(SUM(stock_qty),0) AS total_stock,
|
||
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_usd,
|
||
COALESCE(SUM(CASE WHEN has_risk_bucket AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_cost_usd
|
||
FROM VariantStock
|
||
),
|
||
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(MAX(StockTotals.total_stock),0),
|
||
COALESCE(MAX(StockTotals.stock_cost_usd),0),
|
||
COALESCE(MAX(StockTotals.risk_cost_usd),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
|
||
CROSS JOIN StockTotals
|
||
`).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 = 500
|
||
} else if limit > 50000 {
|
||
limit = 50000
|
||
}
|
||
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
|
||
),
|
||
Dim AS (
|
||
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
item_description,
|
||
COALESCE(kategori,'') AS kategori,
|
||
seri,
|
||
yas_grubu,
|
||
askili_yan,
|
||
urun_ilk_grubu,
|
||
urun_ana_grubu,
|
||
urun_alt_grubu
|
||
FROM (
|
||
SELECT
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
item_description,
|
||
kategori,
|
||
seri,
|
||
yas_grubu,
|
||
askili_yan,
|
||
urun_ilk_grubu,
|
||
urun_ana_grubu,
|
||
urun_alt_grubu,
|
||
1 AS src_rank
|
||
FROM mk_product_performance_kpi_daily
|
||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||
UNION ALL
|
||
SELECT
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
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,
|
||
2 AS src_rank
|
||
FROM mk_product_performance_sales_daily
|
||
GROUP BY product_code, color_code, yaka_kodu
|
||
) x
|
||
ORDER BY product_code, color_code, yaka_kodu, src_rank
|
||
),
|
||
StockOnly AS (
|
||
SELECT
|
||
s.product_code,
|
||
s.color_code,
|
||
s.yaka_kodu,
|
||
'STOK'::text AS market_key,
|
||
COALESCE(MAX(d.item_description),'') AS item_description,
|
||
COALESCE(MAX(d.kategori),'') AS kategori,
|
||
COALESCE(MAX(d.seri),'') AS seri,
|
||
COALESCE(MAX(d.yas_grubu),'') AS yas_grubu,
|
||
COALESCE(MAX(d.askili_yan),'') AS askili_yan,
|
||
COALESCE(MAX(d.urun_ilk_grubu),'') AS urun_ilk_grubu,
|
||
COALESCE(MAX(d.urun_ana_grubu),'') AS urun_ana_grubu,
|
||
COALESCE(MAX(d.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
|
||
LEFT JOIN Dim d
|
||
ON d.product_code = s.product_code
|
||
AND d.color_code = s.color_code
|
||
AND d.yaka_kodu = s.yaka_kodu
|
||
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 = 500
|
||
} else if limit > 50000 {
|
||
limit = 50000
|
||
}
|
||
|
||
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 = 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))),
|
||
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 = '',
|
||
UrunIlkGrubu = 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))),
|
||
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),
|
||
UrunIlkGrubu = MAX(o.UrunIlkGrubu),
|
||
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.UrunIlkGrubu, &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
|
||
}
|
||
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
|
||
}
|
||
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range out {
|
||
row := &out[i]
|
||
price := priceByProduct[row.ProductCode]
|
||
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||
if strings.TrimSpace(row.ItemDescription) == "" {
|
||
row.ItemDescription = attr.itemDescription
|
||
}
|
||
if strings.TrimSpace(row.Kategori) == "" {
|
||
row.Kategori = attr.kategori
|
||
}
|
||
if strings.TrimSpace(row.AskiliYan) == "" {
|
||
row.AskiliYan = attr.askiliYan
|
||
}
|
||
if strings.TrimSpace(row.UrunIlkGrubu) == "" {
|
||
row.UrunIlkGrubu = attr.urunIlkGrubu
|
||
}
|
||
if strings.TrimSpace(row.UrunAnaGrubu) == "" {
|
||
row.UrunAnaGrubu = attr.urunAnaGrubu
|
||
}
|
||
if strings.TrimSpace(row.UrunAltGrubu) == "" {
|
||
row.UrunAltGrubu = attr.urunAltGrubu
|
||
}
|
||
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
|
||
}
|
||
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range out {
|
||
row := &out[i]
|
||
price := priceByProduct[row.ProductCode]
|
||
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
||
if strings.TrimSpace(row.ItemDescription) == "" {
|
||
row.ItemDescription = attr.itemDescription
|
||
}
|
||
row.Kategori = attr.kategori
|
||
row.AskiliYan = attr.askiliYan
|
||
row.UrunIlkGrubu = attr.urunIlkGrubu
|
||
row.UrunAnaGrubu = attr.urunAnaGrubu
|
||
row.UrunAltGrubu = attr.urunAltGrubu
|
||
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 = 800
|
||
} else if limit > 50000 {
|
||
limit = 50000
|
||
}
|
||
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 ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceSalesBreakdownRow, error) {
|
||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||
return nil, err
|
||
}
|
||
if limit <= 0 {
|
||
limit = 500
|
||
} else if limit > 50000 {
|
||
limit = 50000
|
||
}
|
||
mode := strings.ToLower(strings.TrimSpace(breakdown))
|
||
fields := map[string]string{
|
||
"product_code": "s.product_code",
|
||
"color_code": "s.color_code",
|
||
"yaka_kodu": "s.yaka_kodu",
|
||
"item_description": "MAX(s.item_description)",
|
||
"kategori": "COALESCE(MAX(s.kategori),'')",
|
||
"askili_yan": "MAX(s.askili_yan)",
|
||
"urun_ilk_grubu": "COALESCE(MAX(s.urun_ilk_grubu),'')",
|
||
"urun_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')",
|
||
"urun_alt_grubu": "MAX(s.urun_alt_grubu)",
|
||
"market_key": "s.market_key",
|
||
"country": "s.customer_country",
|
||
"customer_segment": "s.customer_segment",
|
||
"customer_code": "s.customer_code",
|
||
"customer_name": "MAX(s.customer_name)",
|
||
}
|
||
selected := map[string]bool{}
|
||
groupCols := []string{}
|
||
switch mode {
|
||
case "color_yaka_market_customer":
|
||
selected["product_code"] = true
|
||
selected["color_code"] = true
|
||
selected["yaka_kodu"] = true
|
||
selected["item_description"] = true
|
||
selected["kategori"] = true
|
||
selected["askili_yan"] = true
|
||
selected["urun_ilk_grubu"] = true
|
||
selected["urun_ana_grubu"] = true
|
||
selected["urun_alt_grubu"] = true
|
||
selected["country"] = true
|
||
selected["customer_segment"] = true
|
||
selected["market_key"] = true
|
||
selected["customer_code"] = true
|
||
selected["customer_name"] = true
|
||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.urun_ilk_grubu", "s.askili_yan", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||
case "product_country_segment_market_customer":
|
||
selected["product_code"] = true
|
||
selected["item_description"] = true
|
||
selected["kategori"] = true
|
||
selected["askili_yan"] = true
|
||
selected["urun_ilk_grubu"] = true
|
||
selected["urun_ana_grubu"] = true
|
||
selected["urun_alt_grubu"] = true
|
||
selected["color_code"] = true
|
||
selected["yaka_kodu"] = true
|
||
selected["country"] = true
|
||
selected["customer_segment"] = true
|
||
selected["market_key"] = true
|
||
selected["customer_code"] = true
|
||
selected["customer_name"] = true
|
||
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu", "s.customer_country", "s.customer_segment"}
|
||
case "market_customer_product":
|
||
selected["market_key"] = true
|
||
selected["customer_code"] = true
|
||
selected["customer_name"] = true
|
||
selected["product_code"] = true
|
||
selected["color_code"] = true
|
||
selected["yaka_kodu"] = true
|
||
selected["item_description"] = true
|
||
selected["kategori"] = true
|
||
selected["askili_yan"] = true
|
||
selected["urun_ilk_grubu"] = true
|
||
selected["urun_ana_grubu"] = true
|
||
selected["urun_alt_grubu"] = true
|
||
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
||
case "country_segment_market_customer_product":
|
||
selected["country"] = true
|
||
selected["customer_segment"] = true
|
||
selected["market_key"] = true
|
||
selected["customer_code"] = true
|
||
selected["customer_name"] = true
|
||
selected["product_code"] = true
|
||
selected["color_code"] = true
|
||
selected["yaka_kodu"] = true
|
||
selected["item_description"] = true
|
||
selected["kategori"] = true
|
||
selected["askili_yan"] = true
|
||
selected["urun_ilk_grubu"] = true
|
||
selected["urun_ana_grubu"] = true
|
||
selected["urun_alt_grubu"] = true
|
||
groupCols = []string{"s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
||
default:
|
||
mode = "color_yaka_market_customer"
|
||
selected["product_code"] = true
|
||
selected["color_code"] = true
|
||
selected["yaka_kodu"] = true
|
||
selected["item_description"] = true
|
||
selected["kategori"] = true
|
||
selected["askili_yan"] = true
|
||
selected["urun_ilk_grubu"] = true
|
||
selected["urun_ana_grubu"] = true
|
||
selected["urun_alt_grubu"] = true
|
||
selected["country"] = true
|
||
selected["customer_segment"] = true
|
||
selected["market_key"] = true
|
||
selected["customer_code"] = true
|
||
selected["customer_name"] = true
|
||
groupCols = []string{"s.color_code", "s.yaka_kodu", "s.urun_ilk_grubu", "s.askili_yan", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
||
}
|
||
|
||
selectExpr := func(name string) string {
|
||
if selected[name] {
|
||
return fields[name] + " AS " + name
|
||
}
|
||
return "''::text AS " + name
|
||
}
|
||
|
||
query := fmt.Sprintf(`
|
||
WITH LatestKPI AS (
|
||
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
COALESCE(kategori,'') AS kategori,
|
||
askili_yan,
|
||
COALESCE(urun_ilk_grubu,'') AS urun_ilk_grubu,
|
||
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
||
urun_alt_grubu
|
||
FROM mk_product_performance_kpi_daily
|
||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||
),
|
||
StockAgg AS (
|
||
SELECT
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
COALESCE(SUM(stock_qty),0) AS stock_qty
|
||
FROM mk_product_performance_stock_daily
|
||
WHERE stock_date = (SELECT MAX(stock_date) FROM mk_product_performance_stock_daily)
|
||
GROUP BY product_code, color_code, yaka_kodu
|
||
),
|
||
Agg AS (
|
||
SELECT
|
||
$2::text AS breakdown,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
COUNT(DISTINCT product_code) AS product_count,
|
||
COALESCE(MAX(stock_qty),0) AS stock_qty,
|
||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '')::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 '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,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * base_price_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 base_price_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_qty * cost_price_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 cost_price_usd_90d,
|
||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_180d,
|
||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)::integer AS invoice_count_180d,
|
||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_qty_180d,
|
||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_usd_180d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
END AS avg_price_usd_180d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
END AS base_price_usd_180d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
||
END AS cost_price_usd_180d,
|
||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d,
|
||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d,
|
||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_365d,
|
||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
END AS avg_price_usd_365d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
END AS base_price_usd_365d,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
||
END AS cost_price_usd_365d,
|
||
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_total,
|
||
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)::integer AS invoice_count_total,
|
||
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_qty_total,
|
||
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_usd_total,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
END AS avg_price_usd_total,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
END AS base_price_usd_total,
|
||
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
||
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
||
END AS cost_price_usd_total,
|
||
COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date
|
||
FROM (
|
||
SELECT
|
||
s.*,
|
||
COALESCE(pd.base_price_usd,0) AS base_price_usd,
|
||
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
|
||
COALESCE(st.stock_qty,0) AS stock_qty
|
||
FROM mk_product_performance_sales_daily s
|
||
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
|
||
LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu
|
||
LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu
|
||
) s
|
||
WHERE sales_date >= DATE '2022-01-01'
|
||
GROUP BY %s
|
||
),
|
||
Enriched AS (
|
||
SELECT
|
||
Agg.*,
|
||
sales_usd_90d - (sales_qty_90d * base_price_usd_90d) AS gross_profit_base_usd_90d,
|
||
sales_usd_90d - (sales_qty_90d * cost_price_usd_90d) AS gross_profit_cost_usd_90d,
|
||
CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * base_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_base_90d,
|
||
CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * cost_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_cost_90d,
|
||
sales_usd_180d - (sales_qty_180d * base_price_usd_180d) AS gross_profit_base_usd_180d,
|
||
sales_usd_180d - (sales_qty_180d * cost_price_usd_180d) AS gross_profit_cost_usd_180d,
|
||
CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * base_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_base_180d,
|
||
CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * cost_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_cost_180d,
|
||
sales_usd_365d - (sales_qty_365d * base_price_usd_365d) AS gross_profit_base_usd_365d,
|
||
sales_usd_365d - (sales_qty_365d * cost_price_usd_365d) AS gross_profit_cost_usd_365d,
|
||
CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * base_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_base_365d,
|
||
CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * cost_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_cost_365d,
|
||
sales_usd_total - (sales_qty_total * base_price_usd_total) AS gross_profit_base_usd_total,
|
||
sales_usd_total - (sales_qty_total * cost_price_usd_total) AS gross_profit_cost_usd_total,
|
||
CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * base_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_base_total,
|
||
CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * cost_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_cost_total,
|
||
(base_price_usd_total > 0 AND cost_price_usd_total > 0) AS has_cost
|
||
FROM Agg
|
||
),
|
||
Scored AS (
|
||
SELECT
|
||
Enriched.*,
|
||
CASE WHEN AVG(NULLIF(sales_usd_90d,0)) OVER () IS NULL THEN 0
|
||
ELSE sales_usd_90d / NULLIF(AVG(NULLIF(sales_usd_90d,0)) OVER (),0)
|
||
END AS sales_index_90d
|
||
FROM Enriched
|
||
)
|
||
SELECT
|
||
breakdown,
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
item_description,
|
||
kategori,
|
||
askili_yan,
|
||
urun_ilk_grubu,
|
||
urun_ana_grubu,
|
||
urun_alt_grubu,
|
||
market_key,
|
||
country,
|
||
customer_segment,
|
||
customer_code,
|
||
customer_name,
|
||
product_count,
|
||
stock_qty,
|
||
customer_count_90d,
|
||
invoice_count_90d,
|
||
sales_qty_90d,
|
||
sales_usd_90d,
|
||
avg_price_usd_90d,
|
||
base_price_usd_90d,
|
||
cost_price_usd_90d,
|
||
gross_profit_base_usd_90d,
|
||
gross_profit_cost_usd_90d,
|
||
gross_margin_base_90d,
|
||
gross_margin_cost_90d,
|
||
customer_count_180d,
|
||
invoice_count_180d,
|
||
sales_qty_180d,
|
||
sales_usd_180d,
|
||
avg_price_usd_180d,
|
||
base_price_usd_180d,
|
||
cost_price_usd_180d,
|
||
gross_profit_base_usd_180d,
|
||
gross_profit_cost_usd_180d,
|
||
gross_margin_base_180d,
|
||
gross_margin_cost_180d,
|
||
sales_qty_365d,
|
||
sales_usd_365d,
|
||
avg_price_usd_365d,
|
||
base_price_usd_365d,
|
||
cost_price_usd_365d,
|
||
gross_profit_base_usd_365d,
|
||
gross_profit_cost_usd_365d,
|
||
gross_margin_base_365d,
|
||
gross_margin_cost_365d,
|
||
customer_count_365d,
|
||
invoice_count_365d,
|
||
customer_count_total,
|
||
invoice_count_total,
|
||
sales_qty_total,
|
||
sales_usd_total,
|
||
avg_price_usd_total,
|
||
base_price_usd_total,
|
||
cost_price_usd_total,
|
||
gross_profit_base_usd_total,
|
||
gross_profit_cost_usd_total,
|
||
gross_margin_base_total,
|
||
gross_margin_cost_total,
|
||
CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_90d,
|
||
CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_180d,
|
||
CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_365d,
|
||
CASE WHEN stock_qty > 0 THEN sales_qty_total / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_total,
|
||
has_cost,
|
||
sales_index_90d,
|
||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||
ROUND((
|
||
LEAST(35, sales_usd_90d / 1000)
|
||
+ LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55)
|
||
+ LEAST(20, invoice_count_90d * 2)
|
||
+ LEAST(10, sales_qty_90d / 10)
|
||
+ LEAST(10, product_count)
|
||
)::numeric, 4)
|
||
END AS customer_score_90d,
|
||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||
ROUND((
|
||
LEAST(35, sales_usd_180d / 1000)
|
||
+ LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55)
|
||
+ LEAST(20, invoice_count_180d * 2)
|
||
+ LEAST(10, sales_qty_180d / 10)
|
||
+ LEAST(10, product_count)
|
||
)::numeric, 4)
|
||
END AS customer_score_180d,
|
||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||
ROUND((
|
||
LEAST(35, sales_usd_365d / 1000)
|
||
+ LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55)
|
||
+ LEAST(20, invoice_count_365d * 2)
|
||
+ LEAST(10, sales_qty_365d / 10)
|
||
+ LEAST(10, product_count)
|
||
)::numeric, 4)
|
||
END AS customer_score_365d,
|
||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||
ROUND((
|
||
LEAST(35, sales_usd_total / 1000)
|
||
+ LEAST(25, GREATEST(gross_margin_cost_total,0) * 55)
|
||
+ LEAST(20, invoice_count_total * 2)
|
||
+ LEAST(10, sales_qty_total / 10)
|
||
+ LEAST(10, product_count)
|
||
)::numeric, 4)
|
||
END AS customer_score_total,
|
||
CASE WHEN NOT has_cost THEN 0 ELSE
|
||
ROUND(
|
||
LEAST(35, sales_index_90d * 18)
|
||
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60)
|
||
+ LEAST(15, invoice_count_90d * 1.5)
|
||
+ LEAST(10, sales_qty_90d / 10)
|
||
+ LEAST(10, (CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END) * 5),
|
||
4
|
||
)
|
||
END AS performance_score,
|
||
CASE
|
||
WHEN NOT has_cost THEN 'MALIYET_YOK'
|
||
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'YILDIZ_URUN'
|
||
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'STOK_RISKI'
|
||
WHEN gross_margin_cost_90d < 0 THEN 'FIYAT_BASKISI'
|
||
WHEN sales_index_90d < 0.75 THEN 'TAKIP'
|
||
ELSE 'TAKIP'
|
||
END AS performance_bucket,
|
||
CASE
|
||
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Güçlü satış kırılımı'
|
||
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gün zayıf, kontrol et'
|
||
ELSE 'Takip'
|
||
END AS recommendation,
|
||
last_sale_date
|
||
FROM Scored
|
||
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
||
LIMIT $1
|
||
`,
|
||
selectExpr("product_code"),
|
||
selectExpr("color_code"),
|
||
selectExpr("yaka_kodu"),
|
||
selectExpr("item_description"),
|
||
selectExpr("kategori"),
|
||
selectExpr("askili_yan"),
|
||
selectExpr("urun_ilk_grubu"),
|
||
selectExpr("urun_ana_grubu"),
|
||
selectExpr("urun_alt_grubu"),
|
||
selectExpr("market_key"),
|
||
selectExpr("country"),
|
||
selectExpr("customer_segment"),
|
||
selectExpr("customer_code"),
|
||
selectExpr("customer_name"),
|
||
strings.Join(groupCols, ", "),
|
||
)
|
||
|
||
rows, err := pg.QueryContext(ctx, query, limit, mode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := make([]models.ProductPerformanceSalesBreakdownRow, 0, limit)
|
||
for rows.Next() {
|
||
var r models.ProductPerformanceSalesBreakdownRow
|
||
if err := rows.Scan(
|
||
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
||
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
|
||
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
|
||
&r.ProductCount, &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
||
&r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
|
||
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
|
||
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180,
|
||
&r.CostPriceUSD180, &r.GrossProfitBase180, &r.GrossProfitCost180, &r.GrossMarginBase180,
|
||
&r.GrossMarginCost180, &r.SalesQty365, &r.SalesUSD365, &r.AvgPriceUSD365, &r.BasePriceUSD365,
|
||
&r.CostPriceUSD365, &r.GrossProfitBase365, &r.GrossProfitCost365, &r.GrossMarginBase365,
|
||
&r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
|
||
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
|
||
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal,
|
||
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
|
||
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
|
||
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
type ProductPerformanceGroupedRequest struct {
|
||
Mode string
|
||
GroupLevels []string
|
||
ExpandedKeys map[string]bool
|
||
Limit int
|
||
}
|
||
|
||
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
||
if req.Limit <= 0 || req.Limit > 50000 {
|
||
req.Limit = 50000
|
||
}
|
||
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
||
if len(levels) == 0 {
|
||
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||
}
|
||
|
||
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
|
||
return out, err
|
||
}
|
||
|
||
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]map[string]any, 0, len(sourceRows))
|
||
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys)
|
||
return out, nil
|
||
}
|
||
|
||
type productPerformanceSQLGroupFilter struct {
|
||
Field string
|
||
Value string
|
||
}
|
||
|
||
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
||
mode := strings.TrimSpace(req.Mode)
|
||
if mode != "products" && mode != "idle" {
|
||
return nil, false, nil
|
||
}
|
||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||
return nil, true, err
|
||
}
|
||
out := make([]map[string]any, 0, 512)
|
||
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit)
|
||
return out, true, err
|
||
}
|
||
|
||
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error {
|
||
if level >= len(levels) {
|
||
rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
*out = append(*out, rows...)
|
||
return nil
|
||
}
|
||
|
||
field := levels[level]
|
||
rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, row := range rows {
|
||
value := stringFromMap(row, "group_value")
|
||
keyPart := field + ":" + value
|
||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||
row["row_key"] = "group|" + key
|
||
row["key"] = key
|
||
row["level"] = level
|
||
row["group_field"] = field
|
||
row["group_value"] = value
|
||
row["label"] = value
|
||
row["__group"] = true
|
||
row[field] = value
|
||
*out = append(*out, row)
|
||
if expandedKeys[key] {
|
||
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) {
|
||
groupExpr, ok := productPerformanceSQLGroupExpr(field)
|
||
if !ok {
|
||
return nil, fmt.Errorf("unsupported product performance group field: %s", field)
|
||
}
|
||
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
||
SELECT jsonb_build_object(
|
||
'group_value', group_value,
|
||
'label', group_value,
|
||
'count', row_count,
|
||
'recommendation', row_count::text || ' satir',
|
||
'image_product_code', image_product_code,
|
||
'image_color_code', image_color_code,
|
||
'image_yaka_kodu', image_yaka_kodu,
|
||
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END,
|
||
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END,
|
||
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END,
|
||
'item_description', item_description,
|
||
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END,
|
||
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END,
|
||
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END,
|
||
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END,
|
||
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END,
|
||
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END,
|
||
'stock_qty', stock_qty,
|
||
'sales_qty_90d', sales_qty_90d,
|
||
'sales_qty_180d', sales_qty_180d,
|
||
'sales_qty_365d', sales_qty_365d,
|
||
'sales_usd_90d', sales_usd_90d,
|
||
'sales_usd_180d', sales_usd_180d,
|
||
'sales_usd_365d', sales_usd_365d,
|
||
'avg_price_usd_90d', avg_price_usd_90d,
|
||
'avg_price_usd_180d', avg_price_usd_180d,
|
||
'cost_price_usd', cost_price_usd,
|
||
'base_price_usd', base_price_usd,
|
||
'base_price_try', base_price_try,
|
||
'gross_profit_usd_90d', gross_profit_usd_90d,
|
||
'gross_profit_usd_180d', gross_profit_usd_180d,
|
||
'gross_margin_90d', gross_margin_90d,
|
||
'gross_margin_180d', gross_margin_180d,
|
||
'unit_profit_cost_90d', unit_profit_cost_90d,
|
||
'unit_profit_cost_180d', unit_profit_cost_180d,
|
||
'unit_profit_base_90d', unit_profit_base_90d,
|
||
'unit_profit_base_180d', unit_profit_base_180d,
|
||
'market_count_90d', market_count_90d,
|
||
'customer_count_90d', customer_count_90d,
|
||
'sales_index_90d', sales_index_90d,
|
||
'price_index_90d', price_index_90d,
|
||
'margin_index_90d', margin_index_90d,
|
||
'performance_score', performance_score,
|
||
'performance_bucket', performance_bucket,
|
||
'idle_cost_usd', idle_cost_usd,
|
||
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
|
||
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
|
||
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
|
||
) AS row_json
|
||
FROM (
|
||
SELECT
|
||
COALESCE(NULLIF(%s,''), '-') AS group_value,
|
||
COUNT(*)::integer AS row_count,
|
||
(ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code,
|
||
(ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code,
|
||
(ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu,
|
||
COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code,
|
||
COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code,
|
||
COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu,
|
||
COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description,
|
||
COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori,
|
||
COALESCE((ARRAY_AGG(NULLIF(askili_yan,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan,
|
||
COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu,
|
||
COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu,
|
||
COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu,
|
||
COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key,
|
||
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) AS stock_qty,
|
||
COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d,
|
||
COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d,
|
||
COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d,
|
||
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
|
||
COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d,
|
||
COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d,
|
||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d,
|
||
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(cost_price_usd) END AS cost_price_usd,
|
||
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(base_price_usd) END AS base_price_usd,
|
||
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE AVG(base_price_try) END AS base_price_try,
|
||
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
|
||
COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d,
|
||
CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d,
|
||
CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_cost_90d) END AS unit_profit_cost_90d,
|
||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_cost_180d) END AS unit_profit_cost_180d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_base_90d) END AS unit_profit_base_90d,
|
||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_base_180d) END AS unit_profit_base_180d,
|
||
COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d,
|
||
COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(sales_index_90d) END AS sales_index_90d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(price_index_90d) END AS price_index_90d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(margin_index_90d) END AS margin_index_90d,
|
||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(performance_score) END AS performance_score,
|
||
MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket,
|
||
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd
|
||
FROM (
|
||
SELECT
|
||
Source.*,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY COALESCE(NULLIF(%s,''), '-'), product_code, color_code, yaka_kodu
|
||
ORDER BY performance_score DESC NULLS LAST
|
||
) AS stock_variant_rank
|
||
FROM Source
|
||
%s
|
||
) s
|
||
GROUP BY COALESCE(NULLIF(%s,''), '-')
|
||
) g
|
||
ORDER BY group_value
|
||
`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr)
|
||
args = append(args, field)
|
||
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
||
}
|
||
|
||
func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) {
|
||
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
args = append(args, limit)
|
||
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
||
SELECT to_jsonb(t) || jsonb_build_object(
|
||
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
|
||
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
|
||
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
|
||
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
|
||
) AS row_json
|
||
FROM Source t
|
||
%s
|
||
ORDER BY performance_score DESC, sales_usd_90d DESC
|
||
LIMIT $%d
|
||
`, whereSQL, len(args))
|
||
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
||
}
|
||
|
||
func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) {
|
||
rows, err := pg.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []map[string]any{}
|
||
for rows.Next() {
|
||
var raw []byte
|
||
if err := rows.Scan(&raw); err != nil {
|
||
return nil, err
|
||
}
|
||
row := map[string]any{}
|
||
if err := json.Unmarshal(raw, &row); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func productPerformanceSQLSourceCTE(mode string) string {
|
||
idleWhere := ""
|
||
if mode == "idle" {
|
||
idleWhere = `
|
||
AND stock_qty > 0
|
||
AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)`
|
||
}
|
||
return `
|
||
WITH Source AS (
|
||
SELECT
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
item_description,
|
||
kategori,
|
||
seri,
|
||
yas_grubu,
|
||
askili_yan,
|
||
COALESCE(urun_ilk_grubu,'') AS urun_ilk_grubu,
|
||
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
||
urun_alt_grubu,
|
||
market_key,
|
||
COALESCE(stock_qty,0) AS stock_qty,
|
||
COALESCE(sales_qty_90d,0) AS sales_qty_90d,
|
||
COALESCE(sales_qty_180d,0) AS sales_qty_180d,
|
||
COALESCE(sales_qty_365d,0) AS sales_qty_365d,
|
||
COALESCE(sales_usd_90d,0) AS sales_usd_90d,
|
||
COALESCE(sales_usd_180d,0) AS sales_usd_180d,
|
||
COALESCE(sales_usd_365d,0) AS sales_usd_365d,
|
||
COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d,
|
||
COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d,
|
||
COALESCE(cost_price_usd,0) AS cost_price_usd,
|
||
COALESCE(base_price_usd,0) AS base_price_usd,
|
||
COALESCE(base_price_try,0) AS base_price_try,
|
||
COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d,
|
||
COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d,
|
||
COALESCE(gross_margin_90d,0) AS gross_margin_90d,
|
||
COALESCE(gross_margin_180d,0) AS gross_margin_180d,
|
||
COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d,
|
||
COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d,
|
||
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
|
||
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
|
||
COALESCE(market_count_90d,0) AS market_count_90d,
|
||
COALESCE(customer_count_90d,0) AS customer_count_90d,
|
||
COALESCE(sales_index_90d,0) AS sales_index_90d,
|
||
COALESCE(price_index_90d,0) AS price_index_90d,
|
||
COALESCE(margin_index_90d,0) AS margin_index_90d,
|
||
COALESCE(performance_score,0) AS performance_score,
|
||
COALESCE(performance_bucket,'') AS performance_bucket,
|
||
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
|
||
FROM mk_product_performance_kpi_daily
|
||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + idleWhere + `
|
||
)`
|
||
}
|
||
|
||
func productPerformanceSQLGroupExpr(field string) (string, bool) {
|
||
switch field {
|
||
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu":
|
||
return field, true
|
||
case "market_key":
|
||
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) {
|
||
if len(filters) == 0 {
|
||
return "", nil, nil
|
||
}
|
||
parts := make([]string, 0, len(filters))
|
||
args := make([]any, 0, len(filters))
|
||
for _, filter := range filters {
|
||
expr, ok := productPerformanceSQLGroupExpr(filter.Field)
|
||
if !ok {
|
||
return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field)
|
||
}
|
||
args = append(args, normalizeProductPerformanceGroupValue(filter.Value))
|
||
parts = append(parts, fmt.Sprintf("COALESCE(NULLIF(%s,''), '-') = $%d", expr, len(args)))
|
||
}
|
||
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
||
}
|
||
|
||
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||
switch mode {
|
||
case "products":
|
||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||
return structsToMaps(rows), err
|
||
case "idle":
|
||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return productPerformanceIdleSourceRows(structsToMaps(rows)), nil
|
||
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, productPerformanceSalesBreakdownMode(mode), limit)
|
||
return structsToMaps(rows), err
|
||
case "order_product_customers":
|
||
rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit)
|
||
return structsToMaps(rows), err
|
||
case "order_market_details":
|
||
rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, limit)
|
||
return structsToMaps(rows), err
|
||
default:
|
||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||
return structsToMaps(rows), err
|
||
}
|
||
}
|
||
|
||
func productPerformanceSalesBreakdownMode(mode string) string {
|
||
switch strings.TrimSpace(mode) {
|
||
case "sales_color_yaka_market_customer":
|
||
return "color_yaka_market_customer"
|
||
case "sales_product_country_segment_market_customer":
|
||
return "product_country_segment_market_customer"
|
||
case "sales_market_customer_product":
|
||
return "market_customer_product"
|
||
case "sales_country_segment_market_customer_product":
|
||
return "country_segment_market_customer_product"
|
||
default:
|
||
return mode
|
||
}
|
||
}
|
||
|
||
func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
|
||
out := make([]map[string]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
stockQty := floatFromMap(row, "stock_qty")
|
||
salesQty90 := floatFromMap(row, "sales_qty_90d")
|
||
stockDays90 := floatFromMap(row, "stock_days_90d")
|
||
if stockQty <= 0 {
|
||
continue
|
||
}
|
||
if stringFromMap(row, "performance_bucket") != "STOK_RISKI" && salesQty90 != 0 && stockDays90 <= 180 {
|
||
continue
|
||
}
|
||
next := cloneMap(row)
|
||
next["idle_cost_usd"] = stockQty * floatFromMap(row, "cost_price_usd")
|
||
out = append(out, next)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, parentKeys []string, expandedKeys map[string]bool) {
|
||
if level >= len(levels) {
|
||
*out = append(*out, sourceRows...)
|
||
return
|
||
}
|
||
|
||
field := levels[level]
|
||
grouped := make(map[string][]map[string]any)
|
||
for _, row := range sourceRows {
|
||
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||
grouped[value] = append(grouped[value], row)
|
||
}
|
||
|
||
values := make([]string, 0, len(grouped))
|
||
for value := range grouped {
|
||
values = append(values, value)
|
||
}
|
||
sort.Slice(values, func(i, j int) bool {
|
||
return strings.Compare(values[i], values[j]) < 0
|
||
})
|
||
|
||
for _, value := range values {
|
||
keyPart := field + ":" + value
|
||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||
groupRows := grouped[value]
|
||
*out = append(*out, makeProductPerformanceGroupedRow(key, level, field, value, groupRows))
|
||
if expandedKeys[key] {
|
||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, append(parentKeys, keyPart), expandedKeys)
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
||
row := aggregateProductPerformanceRows(rows, field)
|
||
row["__group"] = true
|
||
row["row_key"] = "group|" + key
|
||
row["key"] = key
|
||
row["level"] = level
|
||
row["group_field"] = field
|
||
row["group_value"] = value
|
||
row["label"] = value
|
||
row["count"] = len(rows)
|
||
row[field] = value
|
||
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
|
||
|
||
image := firstProductPerformanceImageSource(rows)
|
||
row["image_product_code"] = stringFromMap(image, "product_code")
|
||
row["image_color_code"] = stringFromMap(image, "color_code")
|
||
row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu")
|
||
return row
|
||
}
|
||
|
||
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
|
||
out := map[string]any{}
|
||
for _, row := range rows {
|
||
for key, value := range row {
|
||
if key == "row_key" || key == "key" {
|
||
continue
|
||
}
|
||
if key == "stock_qty" {
|
||
out[key] = distinctProductPerformanceVariantStockQty(rows)
|
||
continue
|
||
}
|
||
if key == "idle_cost_usd" {
|
||
out[key] = distinctProductPerformanceVariantStockCost(rows)
|
||
continue
|
||
}
|
||
if shouldSumProductPerformanceField(key) {
|
||
out[key] = floatFromAny(out[key]) + floatFromAny(value)
|
||
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
|
||
out[key] = value
|
||
}
|
||
}
|
||
}
|
||
for _, row := range rows {
|
||
for key := range row {
|
||
if shouldAverageProductPerformanceField(key) {
|
||
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
|
||
}
|
||
}
|
||
}
|
||
deriveProductPerformanceGroupMetrics(out, groupField)
|
||
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
||
return out
|
||
}
|
||
|
||
func distinctProductPerformanceVariantStockQty(rows []map[string]any) float64 {
|
||
seen := map[string]bool{}
|
||
total := 0.0
|
||
hasKey := false
|
||
for _, row := range rows {
|
||
key := productPerformanceMapVariantKey(row)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
hasKey = true
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
total += floatFromMap(row, "stock_qty")
|
||
}
|
||
if hasKey {
|
||
return total
|
||
}
|
||
for _, row := range rows {
|
||
total += floatFromMap(row, "stock_qty")
|
||
}
|
||
return total
|
||
}
|
||
|
||
func distinctProductPerformanceVariantStockCost(rows []map[string]any) float64 {
|
||
seen := map[string]bool{}
|
||
total := 0.0
|
||
hasKey := false
|
||
for _, row := range rows {
|
||
key := productPerformanceMapVariantKey(row)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
hasKey = true
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
total += floatFromMap(row, "stock_qty") * floatFromMap(row, "cost_price_usd")
|
||
}
|
||
if hasKey {
|
||
return total
|
||
}
|
||
for _, row := range rows {
|
||
total += floatFromMap(row, "idle_cost_usd")
|
||
}
|
||
return total
|
||
}
|
||
|
||
func productPerformanceMapVariantKey(row map[string]any) string {
|
||
productCode := stringFromMap(row, "product_code")
|
||
if productCode == "" {
|
||
productCode = stringFromMap(row, "image_product_code")
|
||
}
|
||
colorCode := stringFromMap(row, "color_code")
|
||
if colorCode == "" {
|
||
colorCode = stringFromMap(row, "image_color_code")
|
||
}
|
||
yakaKodu := stringFromMap(row, "yaka_kodu")
|
||
if yakaKodu == "" {
|
||
yakaKodu = stringFromMap(row, "image_yaka_kodu")
|
||
}
|
||
if productCode == "" && colorCode == "" && yakaKodu == "" {
|
||
return ""
|
||
}
|
||
return productPerformanceVariantKey(productCode, colorCode, yakaKodu)
|
||
}
|
||
|
||
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) {
|
||
for _, suffix := range []string{"90d", "180d", "365d", "total"} {
|
||
sales := floatFromMap(out, "sales_usd_"+suffix)
|
||
qty := floatFromMap(out, "sales_qty_"+suffix)
|
||
stockQty := floatFromMap(out, "stock_qty")
|
||
if stockQty > 0 {
|
||
out["stock_turnover_"+suffix] = qty / stockQty
|
||
}
|
||
if qty > 0 {
|
||
out["avg_price_usd_"+suffix] = sales / qty
|
||
}
|
||
if sales > 0 {
|
||
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||
}
|
||
if _, ok := out["gross_profit_cost_usd_"+suffix]; ok {
|
||
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
||
}
|
||
if _, ok := out["gross_profit_usd_"+suffix]; ok {
|
||
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
||
}
|
||
}
|
||
}
|
||
orderUSD := floatFromMap(out, "order_usd")
|
||
if orderUSD > 0 {
|
||
out["expected_margin_base"] = floatFromMap(out, "expected_profit_base_usd") / orderUSD
|
||
out["expected_margin_cost"] = floatFromMap(out, "expected_profit_cost_usd") / orderUSD
|
||
}
|
||
orderQty := floatFromMap(out, "order_qty")
|
||
if orderQty > 0 {
|
||
out["avg_order_price_usd"] = orderUSD / orderQty
|
||
}
|
||
if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 {
|
||
out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty
|
||
}
|
||
out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d"))
|
||
out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d"))
|
||
out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d"))
|
||
out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total"))
|
||
out["performance_score"] = productPerformanceGroupScore(out, groupField)
|
||
}
|
||
|
||
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
||
if floatFromMap(row, "order_qty") > 0 || floatFromMap(row, "order_usd") > 0 {
|
||
return productPerformanceOrderGroupScore(row)
|
||
}
|
||
if isProductPerformanceCustomerGroup(groupField) {
|
||
return productPerformanceCustomerSalesGroupScore(row)
|
||
}
|
||
return productPerformanceSalesGroupScore(row)
|
||
}
|
||
|
||
func isProductPerformanceCustomerGroup(groupField string) bool {
|
||
switch strings.TrimSpace(groupField) {
|
||
case "customer_code", "customer_name":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
||
salesIndex := floatFromMap(row, "sales_index_90d")
|
||
margin := floatFromMap(row, "gross_margin_cost_90d")
|
||
if margin == 0 {
|
||
margin = floatFromMap(row, "gross_margin_90d")
|
||
}
|
||
invoiceCount := floatFromMap(row, "invoice_count_90d")
|
||
salesQty := floatFromMap(row, "sales_qty_90d")
|
||
stockTurnover := floatFromMap(row, "stock_turnover_90d")
|
||
if stockTurnover == 0 {
|
||
if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 {
|
||
stockTurnover = salesQty / stockQty
|
||
}
|
||
}
|
||
score := minFloat(35, maxFloat(0, salesIndex)*18) +
|
||
minFloat(30, maxFloat(0, margin)*60) +
|
||
minFloat(15, invoiceCount*1.5) +
|
||
minFloat(10, salesQty/10) +
|
||
minFloat(10, maxFloat(0, stockTurnover)*5)
|
||
return score
|
||
}
|
||
|
||
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
|
||
scores := []float64{
|
||
floatFromMap(row, "customer_score_90d"),
|
||
floatFromMap(row, "customer_score_180d"),
|
||
floatFromMap(row, "customer_score_365d"),
|
||
floatFromMap(row, "customer_score_total"),
|
||
}
|
||
total := 0.0
|
||
count := 0.0
|
||
for _, score := range scores {
|
||
if score <= 0 {
|
||
continue
|
||
}
|
||
total += score
|
||
count++
|
||
}
|
||
if count == 0 {
|
||
return 0
|
||
}
|
||
return total / count
|
||
}
|
||
|
||
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
|
||
salesUSD := floatFromMap(row, "sales_usd")
|
||
margin := floatFromMap(row, "gross_margin_cost")
|
||
if margin == 0 {
|
||
margin = floatFromMap(row, "gross_margin")
|
||
}
|
||
invoiceCount := floatFromMap(row, "invoice_count")
|
||
salesQty := floatFromMap(row, "sales_qty")
|
||
productCount := floatFromMap(row, "product_count")
|
||
return minFloat(35, salesUSD/1000) +
|
||
minFloat(25, maxFloat(0, margin)*55) +
|
||
minFloat(20, invoiceCount*2) +
|
||
minFloat(10, salesQty/10) +
|
||
minFloat(10, productCount)
|
||
}
|
||
|
||
func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
|
||
return map[string]any{
|
||
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
|
||
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
|
||
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
|
||
"invoice_count": floatFromMap(row, "invoice_count_"+suffix),
|
||
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
|
||
"product_count": floatFromMap(row, "product_count"),
|
||
}
|
||
}
|
||
|
||
func productPerformanceOrderGroupScore(row map[string]any) float64 {
|
||
orderUSD := floatFromMap(row, "order_usd")
|
||
margin := floatFromMap(row, "expected_margin_cost")
|
||
orderCount := floatFromMap(row, "order_count")
|
||
orderQty := floatFromMap(row, "order_qty")
|
||
netStockAfterOrder := floatFromMap(row, "net_stock_after_order")
|
||
score := minFloat(35, orderUSD/1000) +
|
||
minFloat(25, maxFloat(0, margin)*60) +
|
||
minFloat(15, orderCount*2) +
|
||
minFloat(15, orderQty/10)
|
||
if netStockAfterOrder >= 0 {
|
||
score += 10
|
||
}
|
||
return score
|
||
}
|
||
|
||
func minFloat(a, b float64) float64 {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func maxFloat(a, b float64) float64 {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||
allowed := map[string]bool{
|
||
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
|
||
"product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
|
||
"customer_name": true, "country": true, "customer_segment": true,
|
||
}
|
||
out := make([]string, 0, len(levels))
|
||
seen := map[string]bool{}
|
||
for _, level := range levels {
|
||
level = strings.TrimSpace(level)
|
||
if allowed[level] && !seen[level] {
|
||
out = append(out, level)
|
||
seen[level] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||
switch mode {
|
||
case "sales_color_yaka_market_customer":
|
||
return []string{"color_code", "yaka_kodu", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
||
case "idle":
|
||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
case "sales_product_country_segment_market_customer":
|
||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
case "sales_market_customer_product":
|
||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
case "sales_country_segment_market_customer_product":
|
||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
case "order_product_customers":
|
||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
case "order_market_details":
|
||
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"}
|
||
default:
|
||
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"}
|
||
}
|
||
}
|
||
|
||
func mapGroupValue(row map[string]any, field string) string {
|
||
switch field {
|
||
case "urun_ilk_grubu":
|
||
return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu"))
|
||
case "market_key":
|
||
return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
||
default:
|
||
return stringFromMap(row, field)
|
||
}
|
||
}
|
||
|
||
func shouldSumProductPerformanceField(field string) bool {
|
||
return strings.HasSuffix(field, "_qty") ||
|
||
strings.HasSuffix(field, "_usd") ||
|
||
strings.HasSuffix(field, "_count") ||
|
||
strings.HasSuffix(field, "_value_usd") ||
|
||
field == "stock_qty" ||
|
||
field == "line_count" ||
|
||
field == "invoice_count" ||
|
||
field == "order_count" ||
|
||
field == "product_count" ||
|
||
field == "market_count" ||
|
||
field == "customer_count" ||
|
||
field == "overdue_qty" ||
|
||
field == "net_stock_after_order" ||
|
||
field == "idle_cost_usd"
|
||
}
|
||
|
||
func shouldAverageProductPerformanceField(field string) bool {
|
||
return strings.HasPrefix(field, "avg_") ||
|
||
strings.HasPrefix(field, "unit_") ||
|
||
strings.HasPrefix(field, "base_price") ||
|
||
strings.HasPrefix(field, "cost_price") ||
|
||
strings.HasPrefix(field, "gross_margin") ||
|
||
strings.HasPrefix(field, "expected_margin") ||
|
||
strings.HasPrefix(field, "sales_index") ||
|
||
strings.HasPrefix(field, "performance_score") ||
|
||
strings.HasPrefix(field, "stock_days") ||
|
||
strings.HasPrefix(field, "stock_turnover")
|
||
}
|
||
|
||
func productPerformanceMetricWeightField(field string) string {
|
||
if strings.Contains(field, "_180d") {
|
||
return "sales_qty_180d"
|
||
}
|
||
if strings.Contains(field, "_365d") {
|
||
return "sales_qty_365d"
|
||
}
|
||
if strings.Contains(field, "_total") {
|
||
return "sales_qty_total"
|
||
}
|
||
if strings.Contains(field, "order") || strings.Contains(field, "expected_") {
|
||
return "order_qty"
|
||
}
|
||
if strings.Contains(field, "stock") {
|
||
return "stock_qty"
|
||
}
|
||
return "sales_qty_90d"
|
||
}
|
||
|
||
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
|
||
var weighted, qty, sum float64
|
||
var count int
|
||
for _, row := range rows {
|
||
value := floatFromMap(row, valueField)
|
||
weight := floatFromMap(row, qtyField)
|
||
if weight > 0 {
|
||
weighted += value * weight
|
||
qty += weight
|
||
}
|
||
if value != 0 {
|
||
sum += value
|
||
count++
|
||
}
|
||
}
|
||
if qty > 0 {
|
||
return weighted / qty
|
||
}
|
||
if count > 0 {
|
||
return sum / float64(count)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
|
||
counts := map[string]int{}
|
||
for _, row := range rows {
|
||
value := stringFromMap(row, field)
|
||
if field == "urun_ilk_grubu" {
|
||
value = cleanProductPerformanceFirstGroup(value)
|
||
}
|
||
if value != "" {
|
||
counts[value]++
|
||
}
|
||
}
|
||
var best string
|
||
var bestCount int
|
||
for value, count := range counts {
|
||
if count > bestCount {
|
||
best = value
|
||
bestCount = count
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func firstProductPerformanceImageSource(rows []map[string]any) map[string]any {
|
||
for _, row := range rows {
|
||
if stringFromMap(row, "product_code") != "" {
|
||
return row
|
||
}
|
||
}
|
||
if len(rows) > 0 {
|
||
return rows[0]
|
||
}
|
||
return map[string]any{}
|
||
}
|
||
|
||
func structsToMaps[T any](rows []T) []map[string]any {
|
||
raw, err := json.Marshal(rows)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var out []map[string]any
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneMap(row map[string]any) map[string]any {
|
||
out := make(map[string]any, len(row))
|
||
for key, value := range row {
|
||
out[key] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeProductPerformanceGroupValue(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "-"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func displayProductPerformanceMarketName(value string) string {
|
||
parts := strings.Split(value, "|")
|
||
for i := len(parts) - 1; i >= 0; i-- {
|
||
part := strings.TrimSpace(parts[i])
|
||
if part != "" {
|
||
return part
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func stringFromMap(row map[string]any, field string) string {
|
||
value, ok := row[field]
|
||
if !ok || value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(fmt.Sprint(value))
|
||
}
|
||
|
||
func cleanProductPerformanceFirstGroup(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
normalized := strings.ToUpper(value)
|
||
normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized)
|
||
switch normalized {
|
||
case "YETISKIN", "YETISKIN/GARSON", "GARSON":
|
||
return ""
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func floatFromMap(row map[string]any, field string) float64 {
|
||
return floatFromAny(row[field])
|
||
}
|
||
|
||
func floatFromAny(value any) float64 {
|
||
switch v := value.(type) {
|
||
case float64:
|
||
return v
|
||
case float32:
|
||
return float64(v)
|
||
case int:
|
||
return float64(v)
|
||
case int64:
|
||
return float64(v)
|
||
case json.Number:
|
||
f, _ := v.Float64()
|
||
return f
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
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, `
|
||
;WITH ActiveWarehouses AS (
|
||
SELECT WarehouseCode
|
||
FROM (VALUES
|
||
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'),
|
||
('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),
|
||
('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36')
|
||
) W(WarehouseCode)
|
||
),
|
||
Stock AS (
|
||
SELECT
|
||
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
|
||
InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1)
|
||
FROM trStock S WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
||
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, '')))
|
||
),
|
||
Pick AS (
|
||
SELECT
|
||
SizeCode = LTRIM(RTRIM(ISNULL(P.ItemDim1Code, ''))),
|
||
PickingQty1 = SUM(P.Qty1)
|
||
FROM PickingStates P WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode))
|
||
WHERE P.ItemTypeCode = 1
|
||
AND LTRIM(RTRIM(P.ItemCode)) = @p1
|
||
AND LTRIM(RTRIM(ISNULL(P.ColorCode, ''))) = @p2
|
||
AND LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))) = @p3
|
||
GROUP BY LTRIM(RTRIM(ISNULL(P.ItemDim1Code, '')))
|
||
),
|
||
Reserve AS (
|
||
SELECT
|
||
SizeCode = LTRIM(RTRIM(ISNULL(R.ItemDim1Code, ''))),
|
||
ReserveQty1 = SUM(R.Qty1)
|
||
FROM ReserveStates R WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode))
|
||
WHERE R.ItemTypeCode = 1
|
||
AND LTRIM(RTRIM(R.ItemCode)) = @p1
|
||
AND LTRIM(RTRIM(ISNULL(R.ColorCode, ''))) = @p2
|
||
AND LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))) = @p3
|
||
GROUP BY LTRIM(RTRIM(ISNULL(R.ItemDim1Code, '')))
|
||
),
|
||
Disp AS (
|
||
SELECT
|
||
SizeCode = LTRIM(RTRIM(ISNULL(D.ItemDim1Code, ''))),
|
||
DispOrderQty1 = SUM(D.Qty1)
|
||
FROM DispOrderStates D WITH(NOLOCK)
|
||
INNER JOIN ActiveWarehouses W
|
||
ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode))
|
||
WHERE D.ItemTypeCode = 1
|
||
AND LTRIM(RTRIM(D.ItemCode)) = @p1
|
||
AND LTRIM(RTRIM(ISNULL(D.ColorCode, ''))) = @p2
|
||
AND LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))) = @p3
|
||
GROUP BY LTRIM(RTRIM(ISNULL(D.ItemDim1Code, '')))
|
||
)
|
||
SELECT
|
||
S.SizeCode,
|
||
StockQty = CAST(ROUND(
|
||
ISNULL(S.InventoryQty1, 0)
|
||
- ISNULL(P.PickingQty1, 0)
|
||
- ISNULL(R.ReserveQty1, 0)
|
||
- ISNULL(D.DispOrderQty1, 0),
|
||
2
|
||
) AS FLOAT)
|
||
FROM Stock S
|
||
LEFT JOIN Pick P ON P.SizeCode = S.SizeCode
|
||
LEFT JOIN Reserve R ON R.SizeCode = S.SizeCode
|
||
LEFT JOIN Disp D ON D.SizeCode = S.SizeCode
|
||
WHERE (
|
||
ISNULL(S.InventoryQty1, 0)
|
||
- ISNULL(P.PickingQty1, 0)
|
||
- ISNULL(R.ReserveQty1, 0)
|
||
- ISNULL(D.DispOrderQty1, 0)
|
||
) <> 0
|
||
ORDER BY S.SizeCode
|
||
`, 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_base_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_90d,0) END)",
|
||
"gross_margin_cost_90d": "gross_margin_90d",
|
||
"gross_margin_base_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_180d,0) END)",
|
||
"gross_margin_cost_180d": "gross_margin_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
|
||
}
|
||
|
||
type productPerformanceAttrInfo struct {
|
||
itemDescription string
|
||
kategori string
|
||
askiliYan string
|
||
urunIlkGrubu string
|
||
urunAnaGrubu string
|
||
urunAltGrubu string
|
||
}
|
||
|
||
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 productPerformanceAttrLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]productPerformanceAttrInfo, error) {
|
||
out := make(map[string]productPerformanceAttrInfo, len(variantKeys))
|
||
if len(variantKeys) == 0 {
|
||
return out, nil
|
||
}
|
||
rows, err := pg.QueryContext(ctx, `
|
||
WITH Latest AS (
|
||
SELECT MAX(kpi_date) AS kpi_date
|
||
FROM mk_product_performance_kpi_daily
|
||
)
|
||
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
||
product_code,
|
||
color_code,
|
||
yaka_kodu,
|
||
COALESCE(item_description,'') AS item_description,
|
||
COALESCE(kategori,'') AS kategori,
|
||
COALESCE(askili_yan,'') AS askili_yan,
|
||
COALESCE(urun_ilk_grubu,'') AS urun_ilk_grubu,
|
||
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
||
COALESCE(urun_alt_grubu,'') AS urun_alt_grubu
|
||
FROM mk_product_performance_kpi_daily
|
||
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
||
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
|
||
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
||
`, pq.Array(variantKeys))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var productCode, colorCode, yakaKodu string
|
||
var p productPerformanceAttrInfo
|
||
if err := rows.Scan(&productCode, &colorCode, &yakaKodu, &p.itemDescription, &p.kategori, &p.askiliYan, &p.urunIlkGrubu, &p.urunAnaGrubu, &p.urunAltGrubu); err != nil {
|
||
return nil, err
|
||
}
|
||
out[productPerformanceVariantKey(productCode, colorCode, yakaKodu)] = 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
|
||
}
|